diff --git a/.gitignore b/.gitignore index e8e2cc51d3..a8309316f0 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,5 @@ typesversions !.yarn/versions .pnp.* *.tgz + +tsconfig.vitest-temp.json \ No newline at end of file diff --git a/packages/toolkit/src/dynamicMiddleware/tests/index.test-d.ts b/packages/toolkit/src/dynamicMiddleware/tests/index.test-d.ts new file mode 100644 index 0000000000..c34bc364b7 --- /dev/null +++ b/packages/toolkit/src/dynamicMiddleware/tests/index.test-d.ts @@ -0,0 +1,95 @@ +import type { Action, Middleware, UnknownAction } from 'redux' +import type { ThunkDispatch } from 'redux-thunk' +import { configureStore } from '../../configureStore' +import { createDynamicMiddleware } from '../index' + +const untypedInstance = createDynamicMiddleware() + +interface AppDispatch extends ThunkDispatch { + (n: 1): 1 +} + +const typedInstance = createDynamicMiddleware() + +declare const staticMiddleware: Middleware<(n: 1) => 1> + +const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => + gDM().prepend(typedInstance.middleware).concat(staticMiddleware), +}) + +declare const compatibleMiddleware: Middleware<{}, number, AppDispatch> +declare const incompatibleMiddleware: Middleware<{}, string, AppDispatch> + +declare const addedMiddleware: Middleware<(n: 2) => 2> + +describe('type tests', () => { + test('instance typed at creation ensures middleware compatibility with store', () => { + const store = configureStore({ + reducer: () => '', + // @ts-expect-error + middleware: (gDM) => gDM().prepend(typedInstance.middleware), + }) + }) + + test('instance typed at creation enforces correct middleware type', () => { + typedInstance.addMiddleware( + compatibleMiddleware, + // @ts-expect-error + incompatibleMiddleware + ) + + const dispatch = store.dispatch( + typedInstance.withMiddleware( + compatibleMiddleware, + // @ts-expect-error + incompatibleMiddleware + ) + ) + }) + + test('withTypes() enforces correct middleware type', () => { + const addMiddleware = untypedInstance.addMiddleware.withTypes<{ + state: number + dispatch: AppDispatch + }>() + + addMiddleware( + compatibleMiddleware, + // @ts-expect-error + incompatibleMiddleware + ) + + const withMiddleware = untypedInstance.withMiddleware.withTypes<{ + state: number + dispatch: AppDispatch + }>() + + const dispatch = store.dispatch( + withMiddleware( + compatibleMiddleware, + // @ts-expect-error + incompatibleMiddleware + ) + ) + }) + + test('withMiddleware returns typed dispatch, with any applicable extensions', () => { + const dispatch = store.dispatch( + typedInstance.withMiddleware(addedMiddleware) + ) + + // standard + expectTypeOf(dispatch({ type: 'foo' })).toEqualTypeOf>() + + // thunk + expectTypeOf(dispatch(() => 'foo')).toBeString() + + // static + expectTypeOf(dispatch(1)).toEqualTypeOf<1>() + + // added + expectTypeOf(dispatch(2)).toEqualTypeOf<2>() + }) +}) diff --git a/packages/toolkit/src/dynamicMiddleware/tests/index.typetest.ts b/packages/toolkit/src/dynamicMiddleware/tests/index.typetest.ts deleted file mode 100644 index b554c8751a..0000000000 --- a/packages/toolkit/src/dynamicMiddleware/tests/index.typetest.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* eslint-disable no-lone-blocks */ -import type { Action, UnknownAction, Middleware } from 'redux' -import type { ThunkDispatch } from 'redux-thunk' -import { createDynamicMiddleware } from '../index' -import { configureStore } from '../../configureStore' -import { expectExactType, expectType } from '../../tests/utils/typeTestHelpers' - -const untypedInstance = createDynamicMiddleware() - -interface AppDispatch extends ThunkDispatch { - (n: 1): 1 -} - -const typedInstance = createDynamicMiddleware() - -/** - * Test: instance typed at creation ensures middleware compatibility with store - */ -{ - const store = configureStore({ - reducer: () => '', - // @ts-expect-error - middleware: (gDM) => gDM().prepend(typedInstance.middleware), - }) -} - -declare const staticMiddleware: Middleware<(n: 1) => 1> - -const store = configureStore({ - reducer: () => 0, - middleware: (gDM) => - gDM().prepend(typedInstance.middleware).concat(staticMiddleware), -}) - -declare const compatibleMiddleware: Middleware<{}, number, AppDispatch> -declare const incompatibleMiddleware: Middleware<{}, string, AppDispatch> - -/** - * Test: instance typed at creation enforces correct middleware type - */ -{ - typedInstance.addMiddleware( - compatibleMiddleware, - // @ts-expect-error - incompatibleMiddleware - ) - - const dispatch = store.dispatch( - typedInstance.withMiddleware( - compatibleMiddleware, - // @ts-expect-error - incompatibleMiddleware - ) - ) -} - -/** - * Test: withTypes() enforces correct middleware type - */ -{ - const addMiddleware = untypedInstance.addMiddleware.withTypes<{ - state: number - dispatch: AppDispatch - }>() - - addMiddleware( - compatibleMiddleware, - // @ts-expect-error - incompatibleMiddleware - ) - - const withMiddleware = untypedInstance.withMiddleware.withTypes<{ - state: number - dispatch: AppDispatch - }>() - - const dispatch = store.dispatch( - withMiddleware( - compatibleMiddleware, - // @ts-expect-error - incompatibleMiddleware - ) - ) -} - -declare const addedMiddleware: Middleware<(n: 2) => 2> - -/** - * Test: withMiddleware returns typed dispatch, with any applicable extensions - */ -{ - const dispatch = store.dispatch(typedInstance.withMiddleware(addedMiddleware)) - - // standard - expectType>(dispatch({ type: 'foo' })) - // thunk - expectType(dispatch(() => 'foo')) - // static - expectExactType(1 as const)(dispatch(1)) - // added - expectExactType(2 as const)(dispatch(2)) -} diff --git a/packages/toolkit/src/dynamicMiddleware/tests/react.test-d.ts b/packages/toolkit/src/dynamicMiddleware/tests/react.test-d.ts new file mode 100644 index 0000000000..f7c8d6645a --- /dev/null +++ b/packages/toolkit/src/dynamicMiddleware/tests/react.test-d.ts @@ -0,0 +1,81 @@ +import type { Context } from 'react' +import type { ReactReduxContextValue } from 'react-redux' +import type { Action, Middleware, UnknownAction } from 'redux' +import type { ThunkDispatch } from 'redux-thunk' +import { createDynamicMiddleware } from '../react' + +interface AppDispatch extends ThunkDispatch { + (n: 1): 1 +} + +const untypedInstance = createDynamicMiddleware() + +const typedInstance = createDynamicMiddleware() + +declare const compatibleMiddleware: Middleware<{}, number, AppDispatch> +declare const incompatibleMiddleware: Middleware<{}, string, AppDispatch> + +declare const customContext: Context + +declare const addedMiddleware: Middleware<(n: 2) => 2> + +describe('type tests', () => { + test('instance typed at creation enforces correct middleware type', () => { + const useDispatch = typedInstance.createDispatchWithMiddlewareHook( + compatibleMiddleware, + // @ts-expect-error + incompatibleMiddleware + ) + + const createDispatchWithMiddlewareHook = + typedInstance.createDispatchWithMiddlewareHookFactory(customContext) + const useDispatchWithContext = createDispatchWithMiddlewareHook( + compatibleMiddleware, + // @ts-expect-error + incompatibleMiddleware + ) + }) + + test('withTypes() enforces correct middleware type', () => { + const createDispatchWithMiddlewareHook = + untypedInstance.createDispatchWithMiddlewareHook.withTypes<{ + state: number + dispatch: AppDispatch + }>() + const useDispatch = createDispatchWithMiddlewareHook( + compatibleMiddleware, + // @ts-expect-error + incompatibleMiddleware + ) + + const createCustomDispatchWithMiddlewareHook = untypedInstance + .createDispatchWithMiddlewareHookFactory(customContext) + .withTypes<{ + state: number + dispatch: AppDispatch + }>() + const useCustomDispatch = createCustomDispatchWithMiddlewareHook( + compatibleMiddleware, + // @ts-expect-error + incompatibleMiddleware + ) + }) + + test('useDispatchWithMW returns typed dispatch, with any applicable extensions', () => { + const useDispatchWithMW = + typedInstance.createDispatchWithMiddlewareHook(addedMiddleware) + const dispatch = useDispatchWithMW() + + // standard + expectTypeOf(dispatch({ type: 'foo' })).toEqualTypeOf>() + + // thunk + expectTypeOf(dispatch(() => 'foo')).toBeString() + + // static + expectTypeOf(dispatch(1)).toEqualTypeOf<1>() + + // added + expectTypeOf(dispatch(2)).toEqualTypeOf<2>() + }) +}) diff --git a/packages/toolkit/src/dynamicMiddleware/tests/react.typetest.ts b/packages/toolkit/src/dynamicMiddleware/tests/react.typetest.ts deleted file mode 100644 index a975d80c63..0000000000 --- a/packages/toolkit/src/dynamicMiddleware/tests/react.typetest.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* eslint-disable no-lone-blocks */ -import type { Context } from 'react' -import type { ReactReduxContextValue } from 'react-redux' -import type { Action, UnknownAction, Middleware } from 'redux' -import type { ThunkDispatch } from 'redux-thunk' -import { createDynamicMiddleware } from '../react' -import { expectExactType, expectType } from '../../tests/utils/typeTestHelpers' -/* eslint-disable no-lone-blocks */ - -interface AppDispatch extends ThunkDispatch { - (n: 1): 1 -} - -const untypedInstance = createDynamicMiddleware() - -const typedInstance = createDynamicMiddleware() - -declare const compatibleMiddleware: Middleware<{}, number, AppDispatch> -declare const incompatibleMiddleware: Middleware<{}, string, AppDispatch> - -declare const customContext: Context - -/** - * Test: instance typed at creation enforces correct middleware type - */ -{ - const useDispatch = typedInstance.createDispatchWithMiddlewareHook( - compatibleMiddleware, - // @ts-expect-error - incompatibleMiddleware - ) - - const createDispatchWithMiddlewareHook = - typedInstance.createDispatchWithMiddlewareHookFactory(customContext) - const useDispatchWithContext = createDispatchWithMiddlewareHook( - compatibleMiddleware, - // @ts-expect-error - incompatibleMiddleware - ) -} - -/** - * Test: withTypes() enforces correct middleware type - */ -{ - const createDispatchWithMiddlewareHook = - untypedInstance.createDispatchWithMiddlewareHook.withTypes<{ - state: number - dispatch: AppDispatch - }>() - const useDispatch = createDispatchWithMiddlewareHook( - compatibleMiddleware, - // @ts-expect-error - incompatibleMiddleware - ) - - const createCustomDispatchWithMiddlewareHook = untypedInstance - .createDispatchWithMiddlewareHookFactory(customContext) - .withTypes<{ - state: number - dispatch: AppDispatch - }>() - const useCustomDispatch = createCustomDispatchWithMiddlewareHook( - compatibleMiddleware, - // @ts-expect-error - incompatibleMiddleware - ) -} - -declare const addedMiddleware: Middleware<(n: 2) => 2> - -/** - * Test: useDispatchWithMW returns typed dispatch, with any applicable extensions - */ -{ - const useDispatchWithMW = - typedInstance.createDispatchWithMiddlewareHook(addedMiddleware) - // eslint-disable-next-line react-hooks/rules-of-hooks - const dispatch = useDispatchWithMW() - - // standard - expectType>(dispatch({ type: 'foo' })) - // thunk - expectType(dispatch(() => 'foo')) - // static - expectExactType(1 as const)(dispatch(1)) - // added - expectExactType(2 as const)(dispatch(2)) -} diff --git a/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test-d.ts b/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test-d.ts new file mode 100644 index 0000000000..8ac4c11549 --- /dev/null +++ b/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test-d.ts @@ -0,0 +1,540 @@ +import { createListenerEntry } from '@internal/listenerMiddleware' +import type { + Action, + PayloadAction, + TypedAddListener, + TypedStartListening, + UnknownAction, + UnsubscribeListener, +} from '@reduxjs/toolkit' +import { + addListener, + configureStore, + createAction, + createListenerMiddleware, + createSlice, + isFluxStandardAction, +} from '@reduxjs/toolkit' + +const listenerMiddleware = createListenerMiddleware() +const { startListening } = listenerMiddleware + +const addTypedListenerAction = addListener as TypedAddListener + +interface CounterState { + value: number +} + +const testAction1 = createAction('testAction1') +const testAction2 = createAction('testAction2') + +const counterSlice = createSlice({ + name: 'counter', + initialState: { value: 0 } as CounterState, + reducers: { + increment(state) { + state.value += 1 + }, + decrement(state) { + state.value -= 1 + }, + // Use the PayloadAction type to declare the contents of `action.payload` + incrementByAmount: (state, action: PayloadAction) => { + state.value += action.payload + }, + }, +}) + +const { increment, decrement, incrementByAmount } = counterSlice.actions + +describe('type tests', () => { + const store = configureStore({ + reducer: () => 42, + middleware: (gDM) => gDM().prepend(createListenerMiddleware().middleware), + }) + + test('Allows passing an extra argument on middleware creation', () => { + const originalExtra = 42 + const listenerMiddleware = createListenerMiddleware({ + extra: originalExtra, + }) + const store = configureStore({ + reducer: counterSlice.reducer, + middleware: (gDM) => gDM().prepend(listenerMiddleware.middleware), + }) + + let foundExtra: number | null = null + + const typedAddListener = + listenerMiddleware.startListening as TypedStartListening< + CounterState, + typeof store.dispatch, + typeof originalExtra + > + + typedAddListener({ + matcher: (action): action is Action => true, + effect: (action, listenerApi) => { + foundExtra = listenerApi.extra + + expectTypeOf(listenerApi.extra).toMatchTypeOf(originalExtra) + }, + }) + + store.dispatch(testAction1('a')) + expect(foundExtra).toBe(originalExtra) + }) + + test('unsubscribing via callback from dispatch', () => { + const unsubscribe = store.dispatch( + addListener({ + actionCreator: testAction1, + effect: () => {}, + }), + ) + + expectTypeOf(unsubscribe).toEqualTypeOf() + + store.dispatch(testAction1('a')) + + unsubscribe() + store.dispatch(testAction2('b')) + store.dispatch(testAction1('c')) + }) + + test('take resolves to `[A, CurrentState, PreviousState] | null` if a possibly undefined timeout parameter is provided', () => { + type ExpectedTakeResultType = + | readonly [ReturnType, CounterState, CounterState] + | null + + let timeout: number | undefined = undefined + let done = false + + const startAppListening = + startListening as TypedStartListening + startAppListening({ + predicate: incrementByAmount.match, + effect: async (_, listenerApi) => { + let takeResult = await listenerApi.take(increment.match, timeout) + + timeout = 1 + takeResult = await listenerApi.take(increment.match, timeout) + expect(takeResult).toBeNull() + + expectTypeOf(takeResult).toMatchTypeOf() + + done = true + }, + }) + + expect(done).toBe(true) + }) + + test('State args default to unknown', () => { + createListenerEntry({ + predicate: ( + action, + currentState, + previousState, + ): action is UnknownAction => { + expectTypeOf(currentState).toBeUnknown() + + expectTypeOf(previousState).toBeUnknown() + + return true + }, + effect: (action, listenerApi) => { + const listenerState = listenerApi.getState() + + expectTypeOf(listenerState).toBeUnknown() + + listenerApi.dispatch((dispatch, getState) => { + const thunkState = getState() + + expectTypeOf(thunkState).toBeUnknown() + }) + }, + }) + + startListening({ + predicate: ( + action, + currentState, + previousState, + ): action is UnknownAction => { + expectTypeOf(currentState).toBeUnknown() + + expectTypeOf(previousState).toBeUnknown() + + return true + }, + effect: (action, listenerApi) => {}, + }) + + startListening({ + matcher: increment.match, + effect: (action, listenerApi) => { + const listenerState = listenerApi.getState() + + expectTypeOf(listenerState).toBeUnknown() + + listenerApi.dispatch((dispatch, getState) => { + const thunkState = getState() + + expectTypeOf(thunkState).toBeUnknown() + }) + }, + }) + + store.dispatch( + addListener({ + predicate: ( + action, + currentState, + previousState, + ): action is UnknownAction => { + expectTypeOf(currentState).toBeUnknown() + + expectTypeOf(previousState).toBeUnknown() + + return true + }, + effect: (action, listenerApi) => { + const listenerState = listenerApi.getState() + + expectTypeOf(listenerState).toBeUnknown() + + listenerApi.dispatch((dispatch, getState) => { + const thunkState = getState() + + expectTypeOf(thunkState).toBeUnknown() + }) + }, + }), + ) + + store.dispatch( + addListener({ + matcher: increment.match, + effect: (action, listenerApi) => { + const listenerState = listenerApi.getState() + + expectTypeOf(listenerState).toBeUnknown() + + listenerApi.dispatch((dispatch, getState) => { + const thunkState = getState() + + expectTypeOf(thunkState).toBeUnknown() + }) + }, + }), + ) + }) + + test('Action type is inferred from args', () => { + startListening({ + type: 'abcd', + effect: (action, listenerApi) => { + expectTypeOf(action).toEqualTypeOf<{ type: 'abcd' }>() + }, + }) + + startListening({ + actionCreator: incrementByAmount, + effect: (action, listenerApi) => { + expectTypeOf(action).toMatchTypeOf>() + }, + }) + + startListening({ + matcher: incrementByAmount.match, + effect: (action, listenerApi) => { + expectTypeOf(action).toMatchTypeOf>() + }, + }) + + startListening({ + predicate: ( + action, + currentState, + previousState, + ): action is PayloadAction => { + return ( + isFluxStandardAction(action) && typeof action.payload === 'boolean' + ) + }, + effect: (action, listenerApi) => { + expectTypeOf(action).toEqualTypeOf>() + }, + }) + + startListening({ + predicate: (action, currentState) => { + return ( + isFluxStandardAction(action) && typeof action.payload === 'number' + ) + }, + effect: (action, listenerApi) => { + expectTypeOf(action).toEqualTypeOf() + }, + }) + + store.dispatch( + addListener({ + type: 'abcd', + effect: (action, listenerApi) => { + expectTypeOf(action).toEqualTypeOf<{ type: 'abcd' }>() + }, + }), + ) + + store.dispatch( + addListener({ + actionCreator: incrementByAmount, + effect: (action, listenerApi) => { + expectTypeOf(action).toMatchTypeOf>() + }, + }), + ) + + store.dispatch( + addListener({ + matcher: incrementByAmount.match, + effect: (action, listenerApi) => { + expectTypeOf(action).toMatchTypeOf>() + }, + }), + ) + }) + + test('Can create a pre-typed middleware', () => { + const typedMiddleware = createListenerMiddleware() + + typedMiddleware.startListening({ + predicate: ( + action, + currentState, + previousState, + ): action is UnknownAction => { + expectTypeOf(currentState).not.toBeAny() + + expectTypeOf(previousState).not.toBeAny() + + expectTypeOf(currentState).toEqualTypeOf() + + expectTypeOf(previousState).toEqualTypeOf() + + return true + }, + effect: (action, listenerApi) => { + const listenerState = listenerApi.getState() + + expectTypeOf(listenerState).toEqualTypeOf() + + listenerApi.dispatch((dispatch, getState) => { + const thunkState = listenerApi.getState() + + expectTypeOf(thunkState).toEqualTypeOf() + }) + }, + }) + + // Can pass a predicate function with fewer args + typedMiddleware.startListening({ + predicate: (action, currentState): action is PayloadAction => { + expectTypeOf(currentState).not.toBeAny() + + expectTypeOf(currentState).toEqualTypeOf() + + return true + }, + effect: (action, listenerApi) => { + expectTypeOf(action).toEqualTypeOf>() + + const listenerState = listenerApi.getState() + + expectTypeOf(listenerState).toEqualTypeOf() + + listenerApi.dispatch((dispatch, getState) => { + const thunkState = listenerApi.getState() + + expectTypeOf(thunkState).toEqualTypeOf() + }) + }, + }) + + typedMiddleware.startListening({ + actionCreator: incrementByAmount, + effect: (action, listenerApi) => { + const listenerState = listenerApi.getState() + + expectTypeOf(listenerState).toEqualTypeOf() + + listenerApi.dispatch((dispatch, getState) => { + const thunkState = listenerApi.getState() + + expectTypeOf(thunkState).toEqualTypeOf() + }) + }, + }) + + store.dispatch( + addTypedListenerAction({ + predicate: ( + action, + currentState, + previousState, + ): action is ReturnType => { + expectTypeOf(currentState).not.toBeAny() + + expectTypeOf(previousState).not.toBeAny() + + expectTypeOf(currentState).toEqualTypeOf() + + expectTypeOf(previousState).toEqualTypeOf() + + return true + }, + effect: (action, listenerApi) => { + const listenerState = listenerApi.getState() + + expectTypeOf(listenerState).toEqualTypeOf() + + listenerApi.dispatch((dispatch, getState) => { + const thunkState = listenerApi.getState() + + expectTypeOf(thunkState).toEqualTypeOf() + }) + }, + }), + ) + + store.dispatch( + addTypedListenerAction({ + predicate: ( + action, + currentState, + previousState, + ): action is UnknownAction => { + expectTypeOf(currentState).not.toBeAny() + + expectTypeOf(previousState).not.toBeAny() + + expectTypeOf(currentState).toEqualTypeOf() + + expectTypeOf(previousState).toEqualTypeOf() + + return true + }, + effect: (action, listenerApi) => { + const listenerState = listenerApi.getState() + + expectTypeOf(listenerState).toEqualTypeOf() + + listenerApi.dispatch((dispatch, getState) => { + const thunkState = listenerApi.getState() + + expectTypeOf(thunkState).toEqualTypeOf() + }) + }, + }), + ) + }) + + test('Can create pre-typed versions of startListening and addListener', () => { + const typedAddListener = startListening as TypedStartListening + const typedAddListenerAction = addListener as TypedAddListener + + typedAddListener({ + predicate: ( + action, + currentState, + previousState, + ): action is UnknownAction => { + expectTypeOf(currentState).not.toBeAny() + + expectTypeOf(previousState).not.toBeAny() + + expectTypeOf(currentState).toEqualTypeOf() + + expectTypeOf(previousState).toEqualTypeOf() + + return true + }, + effect: (action, listenerApi) => { + const listenerState = listenerApi.getState() + + expectTypeOf(listenerState).toEqualTypeOf() + + listenerApi.dispatch((dispatch, getState) => { + const thunkState = listenerApi.getState() + + expectTypeOf(thunkState).toEqualTypeOf() + }) + }, + }) + + typedAddListener({ + matcher: incrementByAmount.match, + effect: (action, listenerApi) => { + const listenerState = listenerApi.getState() + + expectTypeOf(listenerState).toEqualTypeOf() + + listenerApi.dispatch((dispatch, getState) => { + const thunkState = listenerApi.getState() + + expectTypeOf(thunkState).toEqualTypeOf() + }) + }, + }) + + store.dispatch( + typedAddListenerAction({ + predicate: ( + action, + currentState, + previousState, + ): action is UnknownAction => { + expectTypeOf(currentState).not.toBeAny() + + expectTypeOf(previousState).not.toBeAny() + + expectTypeOf(currentState).toEqualTypeOf() + + expectTypeOf(previousState).toEqualTypeOf() + + return true + }, + effect: (action, listenerApi) => { + const listenerState = listenerApi.getState() + + expectTypeOf(listenerState).toEqualTypeOf() + + listenerApi.dispatch((dispatch, getState) => { + const thunkState = listenerApi.getState() + + expectTypeOf(thunkState).toEqualTypeOf() + }) + }, + }), + ) + + store.dispatch( + typedAddListenerAction({ + matcher: incrementByAmount.match, + effect: (action, listenerApi) => { + const listenerState = listenerApi.getState() + + expectTypeOf(listenerState).toEqualTypeOf() + + listenerApi.dispatch((dispatch, getState) => { + const thunkState = listenerApi.getState() + + expectTypeOf(thunkState).toEqualTypeOf() + }) + }, + }), + ) + }) +}) diff --git a/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts b/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts index c6af789516..97bfbb50d2 100644 --- a/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts +++ b/packages/toolkit/src/listenerMiddleware/tests/listenerMiddleware.test.ts @@ -1,38 +1,37 @@ import { + TaskAbortError, + addListener, + clearAllListeners, configureStore, createAction, + createListenerMiddleware, createSlice, isAnyOf, - isFluxStandardAction, + removeListener, } from '@reduxjs/toolkit' import type { Mock } from 'vitest' import { vi } from 'vitest' -import type { Action, UnknownAction, PayloadAction } from '@reduxjs/toolkit' - -import { - createListenerMiddleware, - createListenerEntry, - addListener, - removeListener, - TaskAbortError, - clearAllListeners, -} from '../index' - import type { + Action, ListenerEffect, ListenerEffectAPI, + PayloadAction, TypedAddListener, + TypedRemoveListener, TypedStartListening, - UnsubscribeListener, - ListenerMiddleware, -} from '../index' + UnknownAction, +} from '@reduxjs/toolkit' + +import { + listenerCancelled, + listenerCompleted, +} from '@internal/listenerMiddleware/exceptions' + import type { AbortSignalWithReason, AddListenerOverloads, - TypedRemoveListener, -} from '../types' -import { listenerCancelled, listenerCompleted } from '../exceptions' +} from '@internal/listenerMiddleware/types' const middlewareApi = { getState: expect.any(Function), @@ -76,44 +75,6 @@ export function deferred(): Deferred { return Object.assign(promise, methods) as Deferred } -export declare type IsAny = true | false extends ( - T extends never ? true : false -) - ? True - : False - -export declare type IsUnknown = unknown extends T - ? IsAny - : False - -export function expectType(t: T): T { - return t -} - -type Equals = IsAny< - T, - never, - IsAny -> -export function expectExactType(t: T) { - return >(u: U) => {} -} - -type EnsureUnknown = IsUnknown -export function expectUnknown>(t: T) { - return t -} - -type EnsureAny = IsAny -export function expectExactAny>(t: T) { - return t -} - -type IsNotAny = IsAny -export function expectNotAny>(t: T): T { - return t -} - describe('createListenerMiddleware', () => { let store = configureStore({ reducer: () => 42, @@ -150,16 +111,13 @@ describe('createListenerMiddleware', () => { let listenerMiddleware = createListenerMiddleware() let { middleware, startListening, stopListening, clearListeners } = listenerMiddleware - let addTypedListenerAction = addListener as TypedAddListener - let removeTypedListenerAction = + const removeTypedListenerAction = removeListener as TypedRemoveListener const testAction1 = createAction('testAction1') type TestAction1 = ReturnType const testAction2 = createAction('testAction2') - type TestAction2 = ReturnType const testAction3 = createAction('testAction3') - type TestAction3 = ReturnType beforeAll(() => { vi.spyOn(console, 'error').mockImplementation(noop) @@ -202,7 +160,6 @@ describe('createListenerMiddleware', () => { matcher: (action): action is Action => true, effect: (action, listenerApi) => { foundExtra = listenerApi.extra - expectType(listenerApi.extra) }, }) @@ -223,7 +180,7 @@ describe('createListenerMiddleware', () => { startListening({ actionCreator: testAction1, - effect: effect, + effect, }) store.dispatch(testAction1('a')) @@ -300,7 +257,7 @@ describe('createListenerMiddleware', () => { const unsubscribe = startListening({ matcher: isAction1Or2, - effect: effect, + effect, }) store.dispatch(testAction1('a')) @@ -449,8 +406,6 @@ describe('createListenerMiddleware', () => { }) ) - expectType(unsubscribe) - store.dispatch(testAction1('a')) unsubscribe() @@ -986,8 +941,8 @@ describe('createListenerMiddleware', () => { }) test('listenerApi.delay does not trigger unhandledRejections for completed or cancelled listners', async () => { - let deferredCompletedEvt = deferred() - let deferredCancelledEvt = deferred() + const deferredCompletedEvt = deferred() + const deferredCancelledEvt = deferred() const godotPauseTrigger = deferred() // Unfortunately we cannot test declaratively unhandleRejections in jest: https://github.com/facebook/jest/issues/5620 @@ -1209,10 +1164,6 @@ describe('createListenerMiddleware', () => { middleware: (gDM) => gDM().prepend(middleware), }) - type ExpectedTakeResultType = - | readonly [ReturnType, CounterState, CounterState] - | null - let timeout: number | undefined = undefined let done = false @@ -1231,8 +1182,6 @@ describe('createListenerMiddleware', () => { takeResult = await listenerApi.take(increment.match, timeout) expect(takeResult).toBeNull() - expectType(takeResult) - done = true }, }) @@ -1323,8 +1272,8 @@ describe('createListenerMiddleware', () => { }) test('take does not trigger unhandledRejections for completed or cancelled tasks', async () => { - let deferredCompletedEvt = deferred() - let deferredCancelledEvt = deferred() + const deferredCompletedEvt = deferred() + const deferredCancelledEvt = deferred() const store = configureStore({ reducer: counterSlice.reducer, middleware: (gDM) => gDM().prepend(middleware), @@ -1400,364 +1349,4 @@ describe('createListenerMiddleware', () => { expect(jobsCanceled).toBe(2) }) }) - - describe('Type tests', () => { - const listenerMiddleware = createListenerMiddleware() - const { middleware, startListening } = listenerMiddleware - const store = configureStore({ - reducer: counterSlice.reducer, - middleware: (gDM) => gDM().prepend(middleware), - }) - - test('State args default to unknown', () => { - createListenerEntry({ - predicate: ( - action, - currentState, - previousState - ): action is UnknownAction => { - expectUnknown(currentState) - expectUnknown(previousState) - return true - }, - effect: (action, listenerApi) => { - const listenerState = listenerApi.getState() - expectUnknown(listenerState) - listenerApi.dispatch((dispatch, getState) => { - const thunkState = getState() - expectUnknown(thunkState) - }) - }, - }) - - startListening({ - predicate: ( - action, - currentState, - previousState - ): action is UnknownAction => { - expectUnknown(currentState) - expectUnknown(previousState) - return true - }, - effect: (action, listenerApi) => {}, - }) - - startListening({ - matcher: increment.match, - effect: (action, listenerApi) => { - const listenerState = listenerApi.getState() - expectUnknown(listenerState) - listenerApi.dispatch((dispatch, getState) => { - const thunkState = getState() - expectUnknown(thunkState) - }) - }, - }) - - store.dispatch( - addListener({ - predicate: ( - action, - currentState, - previousState - ): action is UnknownAction => { - expectUnknown(currentState) - expectUnknown(previousState) - return true - }, - effect: (action, listenerApi) => { - const listenerState = listenerApi.getState() - expectUnknown(listenerState) - listenerApi.dispatch((dispatch, getState) => { - const thunkState = getState() - expectUnknown(thunkState) - }) - }, - }) - ) - - store.dispatch( - addListener({ - matcher: increment.match, - effect: (action, listenerApi) => { - const listenerState = listenerApi.getState() - expectUnknown(listenerState) - // TODO Can't get the thunk dispatch types to carry through - listenerApi.dispatch((dispatch, getState) => { - const thunkState = getState() - expectUnknown(thunkState) - }) - }, - }) - ) - }) - - test('Action type is inferred from args', () => { - startListening({ - type: 'abcd', - effect: (action, listenerApi) => { - expectType<{ type: 'abcd' }>(action) - }, - }) - - startListening({ - actionCreator: incrementByAmount, - effect: (action, listenerApi) => { - expectType>(action) - }, - }) - - startListening({ - matcher: incrementByAmount.match, - effect: (action, listenerApi) => { - expectType>(action) - }, - }) - - startListening({ - predicate: ( - action, - currentState, - previousState - ): action is PayloadAction => { - return ( - isFluxStandardAction(action) && typeof action.payload === 'boolean' - ) - }, - effect: (action, listenerApi) => { - expectExactType>(action) - }, - }) - - startListening({ - predicate: (action, currentState) => { - return ( - isFluxStandardAction(action) && typeof action.payload === 'number' - ) - }, - effect: (action, listenerApi) => { - expectExactType(action) - }, - }) - - store.dispatch( - addListener({ - type: 'abcd', - effect: (action, listenerApi) => { - expectType<{ type: 'abcd' }>(action) - }, - }) - ) - - store.dispatch( - addListener({ - actionCreator: incrementByAmount, - effect: (action, listenerApi) => { - expectType>(action) - }, - }) - ) - - store.dispatch( - addListener({ - matcher: incrementByAmount.match, - effect: (action, listenerApi) => { - expectType>(action) - }, - }) - ) - }) - - test('Can create a pre-typed middleware', () => { - const typedMiddleware = createListenerMiddleware() - - typedMiddleware.startListening({ - predicate: ( - action, - currentState, - previousState - ): action is UnknownAction => { - expectNotAny(currentState) - expectNotAny(previousState) - expectExactType(currentState) - expectExactType(previousState) - return true - }, - effect: (action, listenerApi) => { - const listenerState = listenerApi.getState() - expectExactType(listenerState) - listenerApi.dispatch((dispatch, getState) => { - const thunkState = listenerApi.getState() - expectExactType(thunkState) - }) - }, - }) - - // Can pass a predicate function with fewer args - typedMiddleware.startListening({ - // TODO Why won't this infer the listener's `action` with implicit argument types? - predicate: ( - action: UnknownAction, - currentState: CounterState - ): action is PayloadAction => { - expectNotAny(currentState) - expectExactType(currentState) - return true - }, - effect: (action, listenerApi) => { - expectType>(action) - - const listenerState = listenerApi.getState() - expectExactType(listenerState) - listenerApi.dispatch((dispatch, getState) => { - const thunkState = listenerApi.getState() - expectExactType(thunkState) - }) - }, - }) - - typedMiddleware.startListening({ - actionCreator: incrementByAmount, - effect: (action, listenerApi) => { - const listenerState = listenerApi.getState() - expectExactType(listenerState) - listenerApi.dispatch((dispatch, getState) => { - const thunkState = listenerApi.getState() - expectExactType(thunkState) - }) - }, - }) - - store.dispatch( - addTypedListenerAction({ - predicate: ( - action, - currentState, - previousState - ): action is ReturnType => { - expectNotAny(currentState) - expectNotAny(previousState) - expectExactType(currentState) - expectExactType(previousState) - return true - }, - effect: (action, listenerApi) => { - const listenerState = listenerApi.getState() - expectExactType(listenerState) - listenerApi.dispatch((dispatch, getState) => { - const thunkState = listenerApi.getState() - expectExactType(thunkState) - }) - }, - }) - ) - - store.dispatch( - addTypedListenerAction({ - predicate: ( - action, - currentState, - previousState - ): action is UnknownAction => { - expectNotAny(currentState) - expectNotAny(previousState) - expectExactType(currentState) - expectExactType(previousState) - return true - }, - effect: (action, listenerApi) => { - const listenerState = listenerApi.getState() - expectExactType(listenerState) - listenerApi.dispatch((dispatch, getState) => { - const thunkState = listenerApi.getState() - expectExactType(thunkState) - }) - }, - }) - ) - }) - - test('Can create pre-typed versions of startListening and addListener', () => { - const typedAddListener = - startListening as TypedStartListening - const typedAddListenerAction = - addListener as TypedAddListener - - typedAddListener({ - predicate: ( - action, - currentState, - previousState - ): action is UnknownAction => { - expectNotAny(currentState) - expectNotAny(previousState) - expectExactType(currentState) - expectExactType(previousState) - return true - }, - effect: (action, listenerApi) => { - const listenerState = listenerApi.getState() - expectExactType(listenerState) - // TODO Can't get the thunk dispatch types to carry through - listenerApi.dispatch((dispatch, getState) => { - const thunkState = listenerApi.getState() - expectExactType(thunkState) - }) - }, - }) - - typedAddListener({ - matcher: incrementByAmount.match, - effect: (action, listenerApi) => { - const listenerState = listenerApi.getState() - expectExactType(listenerState) - // TODO Can't get the thunk dispatch types to carry through - listenerApi.dispatch((dispatch, getState) => { - const thunkState = listenerApi.getState() - expectExactType(thunkState) - }) - }, - }) - - store.dispatch( - typedAddListenerAction({ - predicate: ( - action, - currentState, - previousState - ): action is UnknownAction => { - expectNotAny(currentState) - expectNotAny(previousState) - expectExactType(currentState) - expectExactType(previousState) - return true - }, - effect: (action, listenerApi) => { - const listenerState = listenerApi.getState() - expectExactType(listenerState) - listenerApi.dispatch((dispatch, getState) => { - const thunkState = listenerApi.getState() - expectExactType(thunkState) - }) - }, - }) - ) - - store.dispatch( - typedAddListenerAction({ - matcher: incrementByAmount.match, - effect: (action, listenerApi) => { - const listenerState = listenerApi.getState() - expectExactType(listenerState) - listenerApi.dispatch((dispatch, getState) => { - const thunkState = listenerApi.getState() - expectExactType(thunkState) - }) - }, - }) - ) - }) - }) }) - diff --git a/packages/toolkit/src/query/tests/baseQueryTypes.test-d.ts b/packages/toolkit/src/query/tests/baseQueryTypes.test-d.ts new file mode 100644 index 0000000000..1ffa324b8b --- /dev/null +++ b/packages/toolkit/src/query/tests/baseQueryTypes.test-d.ts @@ -0,0 +1,32 @@ +import { createApi, fetchBaseQuery, retry } from '@reduxjs/toolkit/query' + +describe('type tests', () => { + test('BaseQuery meta types propagate to endpoint callbacks', () => { + createApi({ + baseQuery: fetchBaseQuery(), + endpoints: (build) => ({ + getDummy: build.query({ + query: () => 'dummy', + onCacheEntryAdded: async (arg, { cacheDataLoaded }) => { + const { meta } = await cacheDataLoaded + const { request, response } = meta! // Expect request and response to be there + }, + }), + }), + }) + + const baseQuery = retry(fetchBaseQuery()) // Even when wrapped with retry + createApi({ + baseQuery, + endpoints: (build) => ({ + getDummy: build.query({ + query: () => 'dummy', + onCacheEntryAdded: async (arg, { cacheDataLoaded }) => { + const { meta } = await cacheDataLoaded + const { request, response } = meta! // Expect request and response to be there + }, + }), + }), + }) + }) +}) diff --git a/packages/toolkit/src/query/tests/baseQueryTypes.typetest.ts b/packages/toolkit/src/query/tests/baseQueryTypes.typetest.ts deleted file mode 100644 index f321ced60c..0000000000 --- a/packages/toolkit/src/query/tests/baseQueryTypes.typetest.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { createApi, fetchBaseQuery, retry } from '@reduxjs/toolkit/query' - -/** - * Test: BaseQuery meta types propagate to endpoint callbacks - */ -{ - createApi({ - baseQuery: fetchBaseQuery(), - endpoints: (build) => ({ - getDummy: build.query({ - query: () => 'dummy', - onCacheEntryAdded: async (arg, { cacheDataLoaded }) => { - const { meta } = await cacheDataLoaded - const { request, response } = meta! // Expect request and response to be there - }, - }), - }), - }) - - const baseQuery = retry(fetchBaseQuery()) // Even when wrapped with retry - createApi({ - baseQuery, - endpoints: (build) => ({ - getDummy: build.query({ - query: () => 'dummy', - onCacheEntryAdded: async (arg, { cacheDataLoaded }) => { - const { meta } = await cacheDataLoaded - const { request, response } = meta! // Expect request and response to be there - }, - }), - }), - }) -} diff --git a/packages/toolkit/src/query/tests/buildHooks.test-d.tsx b/packages/toolkit/src/query/tests/buildHooks.test-d.tsx new file mode 100644 index 0000000000..9e15bf68ee --- /dev/null +++ b/packages/toolkit/src/query/tests/buildHooks.test-d.tsx @@ -0,0 +1,263 @@ +import type { UseMutation, UseQuery } from '@internal/query/react/buildHooks' +import { ANY } from '@internal/tests/utils/helpers' +import type { SerializedError } from '@reduxjs/toolkit' +import type { SubscriptionOptions } from '@reduxjs/toolkit/query/react' +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' +import { useState } from 'react' + +let amount = 0 +let nextItemId = 0 + +interface Item { + id: number +} + +const api = createApi({ + baseQuery: (arg: any) => { + if (arg?.body && 'amount' in arg.body) { + amount += 1 + } + + if (arg?.body && 'forceError' in arg.body) { + return { + error: { + status: 500, + data: null, + }, + } + } + + if (arg?.body && 'listItems' in arg.body) { + const items: Item[] = [] + for (let i = 0; i < 3; i++) { + const item = { id: nextItemId++ } + items.push(item) + } + return { data: items } + } + + return { + data: arg?.body ? { ...arg.body, ...(amount ? { amount } : {}) } : {}, + } + }, + endpoints: (build) => ({ + getUser: build.query<{ name: string }, number>({ + query: () => ({ + body: { name: 'Timmy' }, + }), + }), + getUserAndForceError: build.query<{ name: string }, number>({ + query: () => ({ + body: { + forceError: true, + }, + }), + }), + getIncrementedAmount: build.query<{ amount: number }, void>({ + query: () => ({ + url: '', + body: { + amount, + }, + }), + }), + updateUser: build.mutation<{ name: string }, { name: string }>({ + query: (update) => ({ body: update }), + }), + getError: build.query({ + query: () => '/error', + }), + listItems: build.query({ + serializeQueryArgs: ({ endpointName }) => { + return endpointName + }, + query: ({ pageNumber }) => ({ + url: `items?limit=1&offset=${pageNumber}`, + body: { + listItems: true, + }, + }), + merge: (currentCache, newItems) => { + currentCache.push(...newItems) + }, + forceRefetch: () => { + return true + }, + }), + }), +}) + +describe('type tests', () => { + test('useLazyQuery hook callback returns various properties to handle the result', () => { + function User() { + const [getUser] = api.endpoints.getUser.useLazyQuery() + const [{ successMsg, errMsg, isAborted }, setValues] = useState({ + successMsg: '', + errMsg: '', + isAborted: false, + }) + + const handleClick = (abort: boolean) => async () => { + const res = getUser(1) + + // no-op simply for clearer type assertions + res.then((result) => { + if (result.isSuccess) { + expectTypeOf(result).toMatchTypeOf<{ + data: { + name: string + } + }>() + } + + if (result.isError) { + expectTypeOf(result).toMatchTypeOf<{ + error: { status: number; data: unknown } | SerializedError + }>() + } + }) + + expectTypeOf(res.arg).toBeNumber() + + expectTypeOf(res.requestId).toBeString() + + expectTypeOf(res.abort).toEqualTypeOf<() => void>() + + expectTypeOf(res.unsubscribe).toEqualTypeOf<() => void>() + + expectTypeOf(res.updateSubscriptionOptions).toEqualTypeOf< + (options: SubscriptionOptions) => void + >() + + expectTypeOf(res.refetch).toMatchTypeOf<() => void>() + + expectTypeOf(res.unwrap()).resolves.toEqualTypeOf<{ name: string }>() + } + + return ( +
+ + +
{successMsg}
+
{errMsg}
+
{isAborted ? 'Request was aborted' : ''}
+
+ ) + } + }) + + test('useMutation hook callback returns various properties to handle the result', async () => { + function User() { + const [updateUser] = api.endpoints.updateUser.useMutation() + const [successMsg, setSuccessMsg] = useState('') + const [errMsg, setErrMsg] = useState('') + const [isAborted, setIsAborted] = useState(false) + + const handleClick = async () => { + const res = updateUser({ name: 'Banana' }) + + expectTypeOf(res).resolves.toMatchTypeOf< + | { + error: { status: number; data: unknown } | SerializedError + } + | { + data: { + name: string + } + } + >() + + expectTypeOf(res.arg).toMatchTypeOf<{ + endpointName: string + originalArgs: { name: string } + track?: boolean + }>() + + expectTypeOf(res.requestId).toBeString() + + expectTypeOf(res.abort).toEqualTypeOf<() => void>() + + expectTypeOf(res.unwrap()).resolves.toEqualTypeOf<{ name: string }>() + + expectTypeOf(res.reset).toEqualTypeOf<() => void>() + } + + return ( +
+ +
{successMsg}
+
{errMsg}
+
{isAborted ? 'Request was aborted' : ''}
+
+ ) + } + }) + + test('selectFromResult (query) behaviors', () => { + interface Post { + id: number + name: string + fetched_at: string + } + + type PostsResponse = Post[] + + const api = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com/' }), + tagTypes: ['Posts'], + endpoints: (build) => ({ + getPosts: build.query({ + query: () => ({ url: 'posts' }), + providesTags: (result) => + result ? result.map(({ id }) => ({ type: 'Posts', id })) : [], + }), + updatePost: build.mutation>({ + query: ({ id, ...body }) => ({ + url: `post/${id}`, + method: 'PUT', + body, + }), + invalidatesTags: (result, error, { id }) => [{ type: 'Posts', id }], + }), + addPost: build.mutation>({ + query: (body) => ({ + url: `post`, + method: 'POST', + body, + }), + invalidatesTags: ['Posts'], + }), + }), + }) + + expectTypeOf(api.useGetPostsQuery).toEqualTypeOf( + api.endpoints.getPosts.useQuery, + ) + + expectTypeOf(api.useUpdatePostMutation).toEqualTypeOf( + api.endpoints.updatePost.useMutation, + ) + + expectTypeOf(api.useAddPostMutation).toEqualTypeOf( + api.endpoints.addPost.useMutation, + ) + }) + + test('UseQuery type can be used to recreate the hook type', () => { + const fakeQuery = ANY as UseQuery< + typeof api.endpoints.getUser.Types.QueryDefinition + > + + expectTypeOf(fakeQuery).toEqualTypeOf(api.endpoints.getUser.useQuery) + }) + + test('UseMutation type can be used to recreate the hook type', () => { + const fakeMutation = ANY as UseMutation< + typeof api.endpoints.updateUser.Types.MutationDefinition + > + + expectTypeOf(fakeMutation).toEqualTypeOf( + api.endpoints.updateUser.useMutation, + ) + }) +}) diff --git a/packages/toolkit/src/query/tests/buildHooks.test.tsx b/packages/toolkit/src/query/tests/buildHooks.test.tsx index 4371e2442f..868cd6647a 100644 --- a/packages/toolkit/src/query/tests/buildHooks.test.tsx +++ b/packages/toolkit/src/query/tests/buildHooks.test.tsx @@ -1,14 +1,20 @@ -import type { SerializedError, UnknownAction } from '@reduxjs/toolkit' +import type { SubscriptionOptions } from '@internal/query/core/apiState' +import type { SubscriptionSelectors } from '@internal/query/core/buildMiddleware/types' +import { server } from '@internal/query/tests/mocks/server' +import { countObjectKeys } from '@internal/query/utils/countObjectKeys' +import { + actionsReducer, + setupApiStore, + useRenderCounter, + waitMs, + withProvider, +} from '@internal/tests/utils/helpers' +import type { UnknownAction } from '@reduxjs/toolkit' import { configureStore, createListenerMiddleware, createSlice, } from '@reduxjs/toolkit' -import type { SubscriptionOptions } from '@reduxjs/toolkit/dist/query/core/apiState' -import type { - UseMutation, - UseQuery, -} from '@reduxjs/toolkit/dist/query/react/buildHooks' import { QueryStatus, createApi, @@ -27,17 +33,6 @@ import userEvent from '@testing-library/user-event' import { HttpResponse, http } from 'msw' import { useEffect, useState } from 'react' import type { MockInstance } from 'vitest' -import { - actionsReducer, - setupApiStore, - useRenderCounter, - waitMs, - withProvider, -} from '../../tests/utils/helpers' -import { expectExactType, expectType } from '../../tests/utils/typeTestHelpers' -import type { SubscriptionSelectors } from '../core/buildMiddleware/types' -import { countObjectKeys } from '../utils/countObjectKeys' -import { server } from './mocks/server' // Just setup a temporary in-memory counter for tests that `getIncrementedAmount`. // This can be used to test how many renders happen due to data changes or @@ -136,7 +131,7 @@ const storeRef = setupApiStore( middleware: { prepend: [listenerMiddleware.middleware], }, - } + }, ) let getSubscriptions: SubscriptionSelectors['getSubscriptions'] @@ -151,7 +146,7 @@ beforeEach(() => { }, }) ;({ getSubscriptions, getSubscriptionCount } = storeRef.store.dispatch( - api.internalActions.internal_getRTKQSubscriptions() + api.internalActions.internal_getRTKQSubscriptions(), ) as unknown as SubscriptionSelectors) }) @@ -182,7 +177,7 @@ describe('hooks tests', () => { expect(getRenderCount()).toBe(2) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) expect(getRenderCount()).toBe(3) }) @@ -210,14 +205,14 @@ describe('hooks tests', () => { expect(getRenderCount()).toBe(1) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) fireEvent.click(screen.getByText('Increment value')) // setState = 1, perform request = 2 await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('true') + expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) expect(getRenderCount()).toBe(4) @@ -236,7 +231,7 @@ describe('hooks tests', () => { 2, { skip: value < 1, - } + }, )) return (
@@ -253,25 +248,25 @@ describe('hooks tests', () => { // Being that we skipped the initial request on mount, this should be false await waitFor(() => - expect(screen.getByTestId('isLoading').textContent).toBe('false') + expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) fireEvent.click(screen.getByText('Increment value')) // Condition is met, should load await waitFor(() => - expect(screen.getByTestId('isLoading').textContent).toBe('true') + expect(screen.getByTestId('isLoading').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('isLoading').textContent).toBe('false') + expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) // Make sure the original loading has completed. fireEvent.click(screen.getByText('Increment value')) // Being that we already have data, isLoading should be false await waitFor(() => - expect(screen.getByTestId('isLoading').textContent).toBe('false') + expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) // We call a refetch, should still be `false` act(() => void refetch()) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('true') + expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) expect(screen.getByTestId('isLoading').textContent).toBe('false') }) @@ -361,12 +356,12 @@ describe('hooks tests', () => { let { rerender } = render(, { wrapper: storeRef.wrapper }) await waitFor(() => - expect(screen.getByTestId('status').textContent).toBe('1') + expect(screen.getByTestId('status').textContent).toBe('1'), ) rerender() await waitFor(() => - expect(screen.getByTestId('status').textContent).toBe('2') + expect(screen.getByTestId('status').textContent).toBe('2'), ) expect(loadingHist).toEqual([true, false]) @@ -392,14 +387,14 @@ describe('hooks tests', () => { const { unmount } = render(, { wrapper: storeRef.wrapper }) await waitFor(() => - expect(screen.getByTestId('isLoading').textContent).toBe('true') + expect(screen.getByTestId('isLoading').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('isLoading').textContent).toBe('false') + expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) await waitFor(() => - expect(screen.getByTestId('amount').textContent).toBe('1') + expect(screen.getByTestId('amount').textContent).toBe('1'), ) unmount() @@ -408,14 +403,14 @@ describe('hooks tests', () => { // Let's make sure we actually fetch, and we increment expect(screen.getByTestId('isLoading').textContent).toBe('false') await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('true') + expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) await waitFor(() => - expect(screen.getByTestId('amount').textContent).toBe('2') + expect(screen.getByTestId('amount').textContent).toBe('2'), ) }) @@ -438,14 +433,14 @@ describe('hooks tests', () => { const { unmount } = render(, { wrapper: storeRef.wrapper }) await waitFor(() => - expect(screen.getByTestId('isLoading').textContent).toBe('true') + expect(screen.getByTestId('isLoading').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('isLoading').textContent).toBe('false') + expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) await waitFor(() => - expect(screen.getByTestId('amount').textContent).toBe('1') + expect(screen.getByTestId('amount').textContent).toBe('1'), ) unmount() @@ -455,7 +450,7 @@ describe('hooks tests', () => { // and the condition is set to 10 seconds expect(screen.getByTestId('isFetching').textContent).toBe('false') await waitFor(() => - expect(screen.getByTestId('amount').textContent).toBe('1') + expect(screen.getByTestId('amount').textContent).toBe('1'), ) }) @@ -478,14 +473,14 @@ describe('hooks tests', () => { const { unmount } = render(, { wrapper: storeRef.wrapper }) await waitFor(() => - expect(screen.getByTestId('isLoading').textContent).toBe('true') + expect(screen.getByTestId('isLoading').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('isLoading').textContent).toBe('false') + expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) await waitFor(() => - expect(screen.getByTestId('amount').textContent).toBe('1') + expect(screen.getByTestId('amount').textContent).toBe('1'), ) unmount() @@ -496,14 +491,14 @@ describe('hooks tests', () => { render(, { wrapper: storeRef.wrapper }) // Let's make sure we actually fetch, and we increment await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('true') + expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) await waitFor(() => - expect(screen.getByTestId('amount').textContent).toBe('2') + expect(screen.getByTestId('amount').textContent).toBe('2'), ) }) @@ -538,14 +533,14 @@ describe('hooks tests', () => { fireEvent.click(screen.getByText('change skip')) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('true') + expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) await waitFor(() => - expect(screen.getByTestId('amount').textContent).toBe('1') + expect(screen.getByTestId('amount').textContent).toBe('1'), ) }) @@ -584,7 +579,7 @@ describe('hooks tests', () => { fireEvent.click(screen.getByText('change skip')) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('true') + expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => { @@ -621,14 +616,14 @@ describe('hooks tests', () => { fireEvent.click(screen.getByText('change skip')) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('true') + expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) await waitFor(() => - expect(screen.getByTestId('amount').textContent).toBe('2') + expect(screen.getByTestId('amount').textContent).toBe('2'), ) }) @@ -680,7 +675,7 @@ describe('hooks tests', () => { isUninitialized: false, refetch: expect.any(Function), status: 'pending', - }) + }), ) }) test('with `selectFromResult`', async () => { @@ -689,7 +684,7 @@ describe('hooks tests', () => { () => api.endpoints.getUser.useQuery(5, { selectFromResult }), { wrapper: storeRef.wrapper, - } + }, ) await waitFor(() => expect(result.current.isSuccess).toBe(true)) @@ -712,7 +707,7 @@ describe('hooks tests', () => { () => api.endpoints.getIncrementedAmount.useQuery(), { wrapper: storeRef.wrapper, - } + }, ) await waitFor(() => expect(result.current.isSuccess).toBe(true)) @@ -763,7 +758,7 @@ describe('hooks tests', () => { { wrapper: storeRef.wrapper, initialProps: ['a'], - } + }, ) await act(async () => { @@ -839,12 +834,12 @@ describe('hooks tests', () => { () => api.endpoints.getIncrementedAmount.useQuery(), { wrapper: withProvider(store), - } + }, ) } expect(doRender).toThrowError( - /Warning: Middleware for RTK-Query API at reducerPath "api" has not been added to the store/ + /Warning: Middleware for RTK-Query API at reducerPath "api" has not been added to the store/, ) }) }) @@ -881,7 +876,7 @@ describe('hooks tests', () => { expect(getRenderCount()).toBe(1) await waitFor(() => - expect(screen.getByTestId('isUninitialized').textContent).toBe('true') + expect(screen.getByTestId('isUninitialized').textContent).toBe('true'), ) await waitFor(() => expect(data).toBeUndefined()) @@ -889,19 +884,19 @@ describe('hooks tests', () => { expect(getRenderCount()).toBe(2) await waitFor(() => - expect(screen.getByTestId('isUninitialized').textContent).toBe('false') + expect(screen.getByTestId('isUninitialized').textContent).toBe('false'), ) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) expect(getRenderCount()).toBe(3) fireEvent.click(screen.getByTestId('fetchButton')) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('true') + expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) expect(getRenderCount()).toBe(5) }) @@ -942,7 +937,7 @@ describe('hooks tests', () => { expect(getRenderCount()).toBe(1) // hook mount await waitFor(() => - expect(screen.getByTestId('isUninitialized').textContent).toBe('true') + expect(screen.getByTestId('isUninitialized').textContent).toBe('true'), ) await waitFor(() => expect(data).toBeUndefined()) @@ -950,10 +945,10 @@ describe('hooks tests', () => { expect(getRenderCount()).toBe(2) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('true') + expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) expect(getRenderCount()).toBe(3) @@ -962,10 +957,10 @@ describe('hooks tests', () => { fireEvent.click(screen.getByTestId('fetchButton')) // perform new request = 2 await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('true') + expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) expect(getRenderCount()).toBe(6) @@ -976,15 +971,15 @@ describe('hooks tests', () => { fireEvent.click(screen.getByTestId('fetchButton')) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('true') + expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) expect(getRenderCount()).toBe(9) expect( - actions.filter(api.internalActions.updateSubscriptionOptions.match) + actions.filter(api.internalActions.updateSubscriptionOptions.match), ).toHaveLength(1) }) @@ -1013,54 +1008,54 @@ describe('hooks tests', () => { const { unmount } = render(, { wrapper: storeRef.wrapper }) await waitFor(() => - expect(screen.getByTestId('isUninitialized').textContent).toBe('true') + expect(screen.getByTestId('isUninitialized').textContent).toBe('true'), ) await waitFor(() => expect(data).toBeUndefined()) fireEvent.click(screen.getByTestId('fetchUser1')) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('true') + expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) // Being that there is only the initial query, no unsubscribe should be dispatched expect( - actions.filter(api.internalActions.unsubscribeQueryResult.match) + actions.filter(api.internalActions.unsubscribeQueryResult.match), ).toHaveLength(0) fireEvent.click(screen.getByTestId('fetchUser2')) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('true') + expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) expect( - actions.filter(api.internalActions.unsubscribeQueryResult.match) + actions.filter(api.internalActions.unsubscribeQueryResult.match), ).toHaveLength(1) fireEvent.click(screen.getByTestId('fetchUser1')) expect( - actions.filter(api.internalActions.unsubscribeQueryResult.match) + actions.filter(api.internalActions.unsubscribeQueryResult.match), ).toHaveLength(2) // we always unsubscribe the original promise and create a new one fireEvent.click(screen.getByTestId('fetchUser1')) expect( - actions.filter(api.internalActions.unsubscribeQueryResult.match) + actions.filter(api.internalActions.unsubscribeQueryResult.match), ).toHaveLength(3) unmount() // We unsubscribe after the component unmounts expect( - actions.filter(api.internalActions.unsubscribeQueryResult.match) + actions.filter(api.internalActions.unsubscribeQueryResult.match), ).toHaveLength(4) }) @@ -1076,38 +1071,11 @@ describe('hooks tests', () => { const handleClick = (abort: boolean) => async () => { const res = getUser(1) - // no-op simply for clearer type assertions - res.then((result) => { - if (result.isSuccess) { - expectType<{ - data: { - name: string - } - }>(result) - } - if (result.isError) { - expectType<{ - error: { status: number; data: unknown } | SerializedError - }>(result) - } - }) - - expectType(res.arg) - expectType(res.requestId) - expectType<() => void>(res.abort) - expectType<() => Promise<{ name: string }>>(res.unwrap) - expectType<() => void>(res.unsubscribe) - expectType<(options: SubscriptionOptions) => void>( - res.updateSubscriptionOptions - ) - expectType<() => void>(res.refetch) - // abort the query immediately to force an error if (abort) res.abort() res .unwrap() .then((result) => { - expectType<{ name: string }>(result) setValues({ successMsg: `Successfully fetched user ${result.name}`, errMsg: '', @@ -1142,14 +1110,14 @@ describe('hooks tests', () => { expect(screen.queryByText('Request was aborted')).toBeNull() fireEvent.click( - screen.getByRole('button', { name: 'Fetch User and abort' }) + screen.getByRole('button', { name: 'Fetch User and abort' }), ) await screen.findByText('An error has occurred fetching userId: 1') expect(screen.queryByText(/Successfully fetched user/i)).toBeNull() screen.getByText('Request was aborted') fireEvent.click( - screen.getByRole('button', { name: 'Fetch User successfully' }) + screen.getByRole('button', { name: 'Fetch User successfully' }), ) await screen.findByText('Successfully fetched user Timmy') expect(screen.queryByText(/An error has occurred/i)).toBeNull() @@ -1202,7 +1170,7 @@ describe('hooks tests', () => { data: null, }) expect(JSON.parse(unwrappedErrorResult)).toMatchObject( - JSON.parse(errorResult) + JSON.parse(errorResult), ) } }) @@ -1253,7 +1221,7 @@ describe('hooks tests', () => { name: 'Timmy', }) expect(JSON.parse(unwrappedDataResult)).toMatchObject( - JSON.parse(dataResult) + JSON.parse(dataResult), ) } }) @@ -1281,14 +1249,14 @@ describe('hooks tests', () => { render(, { wrapper: storeRef.wrapper }) await waitFor(() => - expect(screen.getByTestId('isLoading').textContent).toBe('false') + expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) fireEvent.click(screen.getByText('Update User')) await waitFor(() => - expect(screen.getByTestId('isLoading').textContent).toBe('true') + expect(screen.getByTestId('isLoading').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('isLoading').textContent).toBe('false') + expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) }) @@ -1313,8 +1281,8 @@ describe('hooks tests', () => { fireEvent.click(screen.getByText('Update User')) await waitFor(() => expect(screen.getByTestId('result').textContent).toBe( - JSON.stringify(result) - ) + JSON.stringify(result), + ), ) }) @@ -1328,41 +1296,16 @@ describe('hooks tests', () => { const handleClick = async () => { const res = updateUser({ name: 'Banana' }) - // no-op simply for clearer type assertions - res.then((result) => { - expectType< - | { - error: { status: number; data: unknown } | SerializedError - } - | { - data: { - name: string - } - } - >(result) - }) - - expectType<{ - endpointName: string - originalArgs: { name: string } - track?: boolean - }>(res.arg) - expectType(res.requestId) - expectType<() => void>(res.abort) - expectType<() => Promise<{ name: string }>>(res.unwrap) - expectType<() => void>(res.reset) - // abort the mutation immediately to force an error res.abort() res .unwrap() .then((result) => { - expectType<{ name: string }>(result) setSuccessMsg(`Successfully updated user ${result.name}`) }) .catch((err) => { setErrMsg( - `An error has occurred updating user ${res.arg.originalArgs.name}` + `An error has occurred updating user ${res.arg.originalArgs.name}`, ) if (err.name === 'AbortError') { setIsAborted(true) @@ -1386,7 +1329,7 @@ describe('hooks tests', () => { expect(screen.queryByText('Request was aborted')).toBeNull() fireEvent.click( - screen.getByRole('button', { name: 'Update User and abort' }) + screen.getByRole('button', { name: 'Update User and abort' }), ) await screen.findByText('An error has occurred updating user Banana') expect(screen.queryByText(/Successfully updated user/i)).toBeNull() @@ -1398,7 +1341,7 @@ describe('hooks tests', () => { () => api.endpoints.updateUser.useMutation(), { wrapper: storeRef.wrapper, - } + }, ) const arg = { name: 'Foo' } @@ -1419,8 +1362,8 @@ describe('hooks tests', () => { {result.isUninitialized ? 'isUninitialized' : result.isSuccess - ? 'isSuccess' - : 'other'} + ? 'isSuccess' + : 'other'} {result.originalArgs?.name} @@ -1473,12 +1416,12 @@ describe('hooks tests', () => { // Resolve initial query await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) userEvent.hover(screen.getByTestId('highPriority')) expect( - api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any) + api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any), ).toEqual({ data: { name: 'Timmy' }, endpointName: 'getUser', @@ -1495,11 +1438,11 @@ describe('hooks tests', () => { }) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) expect( - api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any) + api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any), ).toEqual({ data: { name: 'Timmy' }, endpointName: 'getUser', @@ -1541,13 +1484,13 @@ describe('hooks tests', () => { // Let the initial query resolve await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) // Try to prefetch what we just loaded userEvent.hover(screen.getByTestId('lowPriority')) expect( - api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any) + api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any), ).toEqual({ data: { name: 'Timmy' }, endpointName: 'getUser', @@ -1565,7 +1508,7 @@ describe('hooks tests', () => { await waitMs() expect( - api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any) + api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any), ).toEqual({ data: { name: 'Timmy' }, endpointName: 'getUser', @@ -1606,7 +1549,7 @@ describe('hooks tests', () => { render(, { wrapper: storeRef.wrapper }) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) // Wait 400ms, making it respect ifOlderThan @@ -1615,7 +1558,7 @@ describe('hooks tests', () => { // This should run the query being that we're past the threshold userEvent.hover(screen.getByTestId('lowPriority')) expect( - api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any) + api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any), ).toEqual({ data: { name: 'Timmy' }, endpointName: 'getUser', @@ -1631,11 +1574,11 @@ describe('hooks tests', () => { }) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) expect( - api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any) + api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any), ).toEqual({ data: { name: 'Timmy' }, endpointName: 'getUser', @@ -1676,19 +1619,19 @@ describe('hooks tests', () => { render(, { wrapper: storeRef.wrapper }) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) await waitMs() // Get a snapshot of the last result const latestQueryData = api.endpoints.getUser.select(USER_ID)( - storeRef.store.getState() as any + storeRef.store.getState() as any, ) userEvent.hover(screen.getByTestId('lowPriority')) // Serve up the result from the cache being that the condition wasn't met expect( - api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any) + api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any), ).toEqual(latestQueryData) }) @@ -1716,7 +1659,7 @@ describe('hooks tests', () => { userEvent.hover(screen.getByTestId('lowPriority')) expect( - api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any) + api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any), ).toEqual({ endpointName: 'getUser', isError: false, @@ -1756,14 +1699,14 @@ describe('hooks tests', () => { () => { return HttpResponse.json(null, { status: 500 }) }, - { once: true } + { once: true }, ), http.get('https://example.com/me', () => { return HttpResponse.json(checkSessionData) }), http.post('https://example.com/login', () => { return HttpResponse.json(null, { status: 200 }) - }) + }), ) let data, isLoading, isError function User() { @@ -1784,34 +1727,34 @@ describe('hooks tests', () => { render(, { wrapper: storeRef.wrapper }) await waitFor(() => - expect(screen.getByTestId('isLoading').textContent).toBe('true') + expect(screen.getByTestId('isLoading').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('isLoading').textContent).toBe('false') + expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) await waitFor(() => - expect(screen.getByTestId('isError').textContent).toBe('true') + expect(screen.getByTestId('isError').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('user').textContent).toBe('') + expect(screen.getByTestId('user').textContent).toBe(''), ) fireEvent.click(screen.getByRole('button', { name: /Login/i })) await waitFor(() => - expect(screen.getByTestId('loginLoading').textContent).toBe('true') + expect(screen.getByTestId('loginLoading').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('loginLoading').textContent).toBe('false') + expect(screen.getByTestId('loginLoading').textContent).toBe('false'), ) // login mutation will cause the original errored out query to refire, clearing the error and setting the user await waitFor(() => - expect(screen.getByTestId('isError').textContent).toBe('false') + expect(screen.getByTestId('isError').textContent).toBe('false'), ) await waitFor(() => expect(screen.getByTestId('user').textContent).toBe( - JSON.stringify(checkSessionData) - ) + JSON.stringify(checkSessionData), + ), ) const { checkSession, login } = api.endpoints @@ -1822,7 +1765,7 @@ describe('hooks tests', () => { login.matchPending, login.matchFulfilled, checkSession.matchPending, - checkSession.matchFulfilled + checkSession.matchFulfilled, ) }) }) @@ -1872,14 +1815,14 @@ describe('hooks with createApi defaults set', () => { const { unmount } = render(, { wrapper: storeRef.wrapper }) await waitFor(() => - expect(screen.getByTestId('isLoading').textContent).toBe('true') + expect(screen.getByTestId('isLoading').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('isLoading').textContent).toBe('false') + expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) await waitFor(() => - expect(screen.getByTestId('amount').textContent).toBe('1') + expect(screen.getByTestId('amount').textContent).toBe('1'), ) unmount() @@ -1900,14 +1843,14 @@ describe('hooks with createApi defaults set', () => { render(, { wrapper: storeRef.wrapper }) // Let's make sure we actually fetch, and we increment await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('true') + expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) await waitFor(() => - expect(screen.getByTestId('amount').textContent).toBe('2') + expect(screen.getByTestId('amount').textContent).toBe('2'), ) }) @@ -1928,14 +1871,14 @@ describe('hooks with createApi defaults set', () => { let { unmount } = render(, { wrapper: storeRef.wrapper }) await waitFor(() => - expect(screen.getByTestId('isLoading').textContent).toBe('true') + expect(screen.getByTestId('isLoading').textContent).toBe('true'), ) await waitFor(() => - expect(screen.getByTestId('isLoading').textContent).toBe('false') + expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) await waitFor(() => - expect(screen.getByTestId('amount').textContent).toBe('1') + expect(screen.getByTestId('amount').textContent).toBe('1'), ) unmount() @@ -1956,10 +1899,10 @@ describe('hooks with createApi defaults set', () => { render(, { wrapper: storeRef.wrapper }) await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false') + expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) await waitFor(() => - expect(screen.getByTestId('amount').textContent).toBe('1') + expect(screen.getByTestId('amount').textContent).toBe('1'), ) }) @@ -1998,18 +1941,18 @@ describe('hooks with createApi defaults set', () => { id, name: body?.name || post.name, fetched_at: new Date().toUTCString(), - } + }, ) posts = [...newPosts] return HttpResponse.json(posts) - } + }, ), http.post>( 'https://example.com/post', async ({ request }) => { const body = await request.json() - let post = body + const post = body startingId += 1 posts.concat({ ...post, @@ -2017,7 +1960,7 @@ describe('hooks with createApi defaults set', () => { id: startingId, }) return HttpResponse.json(posts) - } + }, ), ] @@ -2074,12 +2017,6 @@ describe('hooks with createApi defaults set', () => { counter: counterSlice.reducer, }) - expectExactType(api.useGetPostsQuery)(api.endpoints.getPosts.useQuery) - expectExactType(api.useUpdatePostMutation)( - api.endpoints.updatePost.useMutation - ) - expectExactType(api.useAddPostMutation)(api.endpoints.addPost.useMutation) - test('useQueryState serves a deeply memoized value and does not rerender unnecessarily', async () => { function Posts() { const { data: posts } = api.endpoints.getPosts.useQuery() @@ -2121,7 +2058,7 @@ describe('hooks with createApi defaults set', () => {
, - { wrapper: storeRef.wrapper } + { wrapper: storeRef.wrapper }, ) expect(getRenderCount()).toBe(1) @@ -2195,7 +2132,7 @@ describe('hooks with createApi defaults set', () => { , - { wrapper: storeRef.wrapper } + { wrapper: storeRef.wrapper }, ) expect(getRenderCount()).toBe(2) @@ -2247,7 +2184,7 @@ describe('hooks with createApi defaults set', () => { , - { wrapper: storeRef.wrapper } + { wrapper: storeRef.wrapper }, ) expect(getRenderCount()).toBe(1) @@ -2316,7 +2253,7 @@ describe('hooks with createApi defaults set', () => { , - { wrapper: storeRef.wrapper } + { wrapper: storeRef.wrapper }, ) expect(getRenderCount()).toBe(1) @@ -2350,9 +2287,7 @@ describe('hooks with createApi defaults set', () => { return (
- {posts?.map((post) => ( -
{post.name}
- ))} + {posts?.map((post) =>
{post.name}
)}
) } @@ -2375,7 +2310,7 @@ describe('hooks with createApi defaults set', () => { , - { wrapper: storeRef.wrapper } + { wrapper: storeRef.wrapper }, ) await waitFor(() => expect(getRenderCount()).toBe(2)) @@ -2403,7 +2338,7 @@ describe('hooks with createApi defaults set', () => {
, - { wrapper: storeRef.wrapper } + { wrapper: storeRef.wrapper }, ) expect(screen.getByTestId('size2').textContent).toBe('0') @@ -2477,7 +2412,7 @@ describe('hooks with createApi defaults set', () => { increment.matchFulfilled, increment.matchPending, api.internalActions.removeMutationResult.match, - increment.matchFulfilled + increment.matchFulfilled, ) }) @@ -2506,16 +2441,16 @@ describe('hooks with createApi defaults set', () => { fireEvent.click(screen.getByTestId('incrementButton')) await waitFor(() => expect(screen.getByTestId('data').textContent).toBe( - JSON.stringify({ amount: 1 }) - ) + JSON.stringify({ amount: 1 }), + ), ) expect(getRenderCount()).toBe(3) fireEvent.click(screen.getByTestId('incrementButton')) await waitFor(() => expect(screen.getByTestId('data').textContent).toBe( - JSON.stringify({ amount: 2 }) - ) + JSON.stringify({ amount: 2 }), + ), ) expect(getRenderCount()).toBe(5) }) @@ -2544,19 +2479,19 @@ describe('hooks with createApi defaults set', () => { expect(getRenderCount()).toBe(2) // will be pending, isLoading: true, await waitFor(() => - expect(screen.getByTestId('status').textContent).toBe('pending') + expect(screen.getByTestId('status').textContent).toBe('pending'), ) await waitFor(() => - expect(screen.getByTestId('status').textContent).toBe('fulfilled') + expect(screen.getByTestId('status').textContent).toBe('fulfilled'), ) expect(getRenderCount()).toBe(3) fireEvent.click(screen.getByTestId('incrementButton')) await waitFor(() => - expect(screen.getByTestId('status').textContent).toBe('pending') + expect(screen.getByTestId('status').textContent).toBe('pending'), ) await waitFor(() => - expect(screen.getByTestId('status').textContent).toBe('fulfilled') + expect(screen.getByTestId('status').textContent).toBe('fulfilled'), ) expect(getRenderCount()).toBe(5) }) @@ -2603,7 +2538,7 @@ describe('skip behaviour', () => { { wrapper: storeRef.wrapper, initialProps: [1, { skip: true }], - } + }, ) expect(result.current).toEqual(uninitialized) @@ -2636,7 +2571,7 @@ describe('skip behaviour', () => { { wrapper: storeRef.wrapper, initialProps: [skipToken], - } + }, ) expect(result.current).toEqual(uninitialized) @@ -2673,7 +2608,7 @@ describe('skip behaviour', () => { { wrapper: storeRef.wrapper, initialProps: [1], - } + }, ) await act(async () => { @@ -2703,20 +2638,3 @@ describe('skip behaviour', () => { }) }) }) - -// type tests: -{ - const ANY = {} as any - - // UseQuery type can be used to recreate the hook type - const fakeQuery = ANY as UseQuery< - typeof api.endpoints.getUser.Types.QueryDefinition - > - expectExactType(fakeQuery)(api.endpoints.getUser.useQuery) - - // UseMutation type can be used to recreate the hook type - const fakeMutation = ANY as UseMutation< - typeof api.endpoints.updateUser.Types.MutationDefinition - > - expectExactType(fakeMutation)(api.endpoints.updateUser.useMutation) -} diff --git a/packages/toolkit/src/query/tests/buildSelector.test-d.ts b/packages/toolkit/src/query/tests/buildSelector.test-d.ts index 968b70c3a5..15bf0c6be2 100644 --- a/packages/toolkit/src/query/tests/buildSelector.test-d.ts +++ b/packages/toolkit/src/query/tests/buildSelector.test-d.ts @@ -2,7 +2,7 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' import { configureStore, createSelector } from '@reduxjs/toolkit' -describe('buildSelector', () => { +describe('type tests', () => { test('buildSelector type test', () => { interface Todo { userId: number @@ -31,11 +31,12 @@ describe('buildSelector', () => { [exampleQuerySelector], (queryState) => { return queryState?.data?.[0] ?? ({} as Todo) - } + }, ) + const firstTodoTitleSelector = createSelector( [todosSelector], - (todo) => todo?.title + (todo) => todo?.title, ) const store = configureStore({ @@ -49,7 +50,8 @@ describe('buildSelector', () => { // This only compiles if we carried the types through const upperTitle = todoTitle.toUpperCase() - expectTypeOf(upperTitle).toEqualTypeOf() + + expectTypeOf(upperTitle).toBeString() }) test('selectCachedArgsForQuery type test', () => { @@ -82,7 +84,7 @@ describe('buildSelector', () => { }) expectTypeOf( - exampleApi.util.selectCachedArgsForQuery(store.getState(), 'getTodos') + exampleApi.util.selectCachedArgsForQuery(store.getState(), 'getTodos'), ).toEqualTypeOf() }) }) diff --git a/packages/toolkit/src/query/tests/cacheLifecycle.test-d.ts b/packages/toolkit/src/query/tests/cacheLifecycle.test-d.ts new file mode 100644 index 0000000000..3133bbc879 --- /dev/null +++ b/packages/toolkit/src/query/tests/cacheLifecycle.test-d.ts @@ -0,0 +1,31 @@ +import type { FetchBaseQueryMeta } from '@reduxjs/toolkit/query' +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query' + +const api = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), + endpoints: () => ({}), +}) + +describe('type tests', () => { + test(`mutation: await cacheDataLoaded, await cacheEntryRemoved (success)`, () => { + const extended = api.injectEndpoints({ + overrideExisting: true, + endpoints: (build) => ({ + injected: build.mutation({ + query: () => '/success', + async onCacheEntryAdded( + arg, + { dispatch, getState, cacheEntryRemoved, cacheDataLoaded }, + ) { + const firstValue = await cacheDataLoaded + + expectTypeOf(firstValue).toMatchTypeOf<{ + data: number + meta?: FetchBaseQueryMeta + }>() + }, + }), + }), + }) + }) +}) diff --git a/packages/toolkit/src/query/tests/cacheLifecycle.test.ts b/packages/toolkit/src/query/tests/cacheLifecycle.test.ts index e143af311a..7876777857 100644 --- a/packages/toolkit/src/query/tests/cacheLifecycle.test.ts +++ b/packages/toolkit/src/query/tests/cacheLifecycle.test.ts @@ -1,12 +1,10 @@ -import type { FetchBaseQueryMeta } from '@reduxjs/toolkit/query' -import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query' import { DEFAULT_DELAY_MS, fakeTimerWaitFor, setupApiStore, -} from '../../tests/utils/helpers' -import { expectType } from '../../tests/utils/typeTestHelpers' -import type { QueryActionCreatorResult } from '../core/buildInitiate' +} from '@internal/tests/utils/helpers' +import type { QueryActionCreatorResult } from '@reduxjs/toolkit/query' +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query' beforeAll(() => { vi.useFakeTimers() @@ -58,7 +56,7 @@ describe.each([['query'], ['mutation']] as const)( query: () => '/success', async onCacheEntryAdded( arg, - { dispatch, getState, cacheEntryRemoved } + { dispatch, getState, cacheEntryRemoved }, ) { onNewCacheEntry(arg) await cacheEntryRemoved @@ -68,7 +66,7 @@ describe.each([['query'], ['mutation']] as const)( }), }) const promise = storeRef.store.dispatch( - extended.endpoints.injected.initiate('arg') + extended.endpoints.injected.initiate('arg'), ) expect(onNewCacheEntry).toHaveBeenCalledWith('arg') @@ -98,13 +96,10 @@ describe.each([['query'], ['mutation']] as const)( query: () => '/success', async onCacheEntryAdded( arg, - { dispatch, getState, cacheEntryRemoved, cacheDataLoaded } + { dispatch, getState, cacheEntryRemoved, cacheDataLoaded }, ) { onNewCacheEntry(arg) const firstValue = await cacheDataLoaded - expectType<{ data: number; meta?: FetchBaseQueryMeta }>( - firstValue - ) gotFirstValue(firstValue) await cacheEntryRemoved onCleanup() @@ -113,7 +108,7 @@ describe.each([['query'], ['mutation']] as const)( }), }) const promise = storeRef.store.dispatch( - extended.endpoints.injected.initiate('arg') + extended.endpoints.injected.initiate('arg'), ) expect(onNewCacheEntry).toHaveBeenCalledWith('arg') @@ -156,7 +151,7 @@ describe.each([['query'], ['mutation']] as const)( query: () => '/error', // we will initiate only once and that one time will be an error -> cacheDataLoaded will never resolve async onCacheEntryAdded( arg, - { dispatch, getState, cacheEntryRemoved, cacheDataLoaded } + { dispatch, getState, cacheEntryRemoved, cacheDataLoaded }, ) { onNewCacheEntry(arg) // this will wait until cacheEntryRemoved, then reject => nothing past that line will execute @@ -171,7 +166,7 @@ describe.each([['query'], ['mutation']] as const)( }), }) const promise = storeRef.store.dispatch( - extended.endpoints.injected.initiate('arg') + extended.endpoints.injected.initiate('arg'), ) expect(onNewCacheEntry).toHaveBeenCalledWith('arg') @@ -196,7 +191,7 @@ describe.each([['query'], ['mutation']] as const)( query: () => '/error', // we will initiate only once and that one time will be an error -> cacheDataLoaded will never resolve async onCacheEntryAdded( arg, - { dispatch, getState, cacheEntryRemoved, cacheDataLoaded } + { dispatch, getState, cacheEntryRemoved, cacheDataLoaded }, ) { onNewCacheEntry(arg) @@ -214,7 +209,7 @@ describe.each([['query'], ['mutation']] as const)( }), }) const promise = storeRef.store.dispatch( - extended.endpoints.injected.initiate('arg') + extended.endpoints.injected.initiate('arg'), ) expect(onNewCacheEntry).toHaveBeenCalledWith('arg') @@ -247,7 +242,7 @@ describe.each([['query'], ['mutation']] as const)( query: () => '/error', // we will initiate only once and that one time will be an error -> cacheDataLoaded will never resolve async onCacheEntryAdded( arg, - { dispatch, getState, cacheEntryRemoved, cacheDataLoaded } + { dispatch, getState, cacheEntryRemoved, cacheDataLoaded }, ) { onNewCacheEntry(arg) @@ -266,7 +261,7 @@ describe.each([['query'], ['mutation']] as const)( }), }) const promise = storeRef.store.dispatch( - extended.endpoints.injected.initiate('arg') + extended.endpoints.injected.initiate('arg'), ) expect(onNewCacheEntry).toHaveBeenCalledWith('arg') @@ -298,7 +293,7 @@ describe.each([['query'], ['mutation']] as const)( query: () => '/error', // we will initiate only once and that one time will be an error -> cacheDataLoaded will never resolve async onCacheEntryAdded( arg, - { dispatch, getState, cacheEntryRemoved, cacheDataLoaded } + { dispatch, getState, cacheEntryRemoved, cacheDataLoaded }, ) { onNewCacheEntry(arg) @@ -317,7 +312,7 @@ describe.each([['query'], ['mutation']] as const)( }), }) const promise = storeRef.store.dispatch( - extended.endpoints.injected.initiate('arg') + extended.endpoints.injected.initiate('arg'), ) expect(onNewCacheEntry).toHaveBeenCalledWith('arg') @@ -340,7 +335,7 @@ describe.each([['query'], ['mutation']] as const)( message: 'Promise never resolved before cacheEntryRemoved.', }) }) - } + }, ) test(`query: getCacheEntry`, async () => { @@ -358,7 +353,7 @@ test(`query: getCacheEntry`, async () => { getCacheEntry, cacheEntryRemoved, cacheDataLoaded, - } + }, ) { snapshot(getCacheEntry()) gotFirstValue(await cacheDataLoaded) @@ -370,7 +365,7 @@ test(`query: getCacheEntry`, async () => { }), }) const promise = storeRef.store.dispatch( - extended.endpoints.injected.initiate('arg') + extended.endpoints.injected.initiate('arg'), ) await promise promise.unsubscribe() @@ -432,7 +427,7 @@ test(`mutation: getCacheEntry`, async () => { getCacheEntry, cacheEntryRemoved, cacheDataLoaded, - } + }, ) { snapshot(getCacheEntry()) gotFirstValue(await cacheDataLoaded) @@ -444,7 +439,7 @@ test(`mutation: getCacheEntry`, async () => { }), }) const promise = storeRef.store.dispatch( - extended.endpoints.injected.initiate('arg') + extended.endpoints.injected.initiate('arg'), ) await fakeTimerWaitFor(() => { expect(gotFirstValue).toHaveBeenCalled() @@ -502,7 +497,7 @@ test('updateCachedData', async () => { updateCachedData, cacheEntryRemoved, cacheDataLoaded, - } + }, ) { expect(getCacheEntry().data).toEqual(undefined) // calling `updateCachedData` when there is no data yet should not do anything @@ -540,7 +535,7 @@ test('updateCachedData', async () => { }), }) const promise = storeRef.store.dispatch( - extended.endpoints.injected.initiate('arg') + extended.endpoints.injected.initiate('arg'), ) await promise promise.unsubscribe() @@ -575,7 +570,7 @@ test('dispatching further actions does not trigger another lifecycle', async () expect(onNewCacheEntry).toHaveBeenCalledTimes(1) await storeRef.store.dispatch( - extended.endpoints.injected.initiate(undefined, { forceRefetch: true }) + extended.endpoints.injected.initiate(undefined, { forceRefetch: true }), ) expect(onNewCacheEntry).toHaveBeenCalledTimes(1) }) @@ -593,7 +588,7 @@ test('dispatching a query initializer with `subscribe: false` does also start a }), }) await storeRef.store.dispatch( - extended.endpoints.injected.initiate(undefined, { subscribe: false }) + extended.endpoints.injected.initiate(undefined, { subscribe: false }), ) expect(onNewCacheEntry).toHaveBeenCalledTimes(1) @@ -615,7 +610,7 @@ test('dispatching a mutation initializer with `track: false` does not start a li }), }) await storeRef.store.dispatch( - extended.endpoints.injected.initiate(undefined, { track: false }) + extended.endpoints.injected.initiate(undefined, { track: false }), ) expect(onNewCacheEntry).toHaveBeenCalledTimes(0) diff --git a/packages/toolkit/src/query/tests/createApi.test-d.ts b/packages/toolkit/src/query/tests/createApi.test-d.ts new file mode 100644 index 0000000000..b33ced1f6e --- /dev/null +++ b/packages/toolkit/src/query/tests/createApi.test-d.ts @@ -0,0 +1,377 @@ +import { setupApiStore } from '@internal/tests/utils/helpers' +import type { SerializedError } from '@reduxjs/toolkit' +import { configureStore } from '@reduxjs/toolkit' +import type { + DefinitionsFromApi, + FetchBaseQueryError, + MutationDefinition, + OverrideResultType, + QueryDefinition, + TagDescription, + TagTypesFromApi, +} from '@reduxjs/toolkit/query' +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query' + +describe('type tests', () => { + test('sensible defaults', () => { + const api = createApi({ + baseQuery: fetchBaseQuery(), + endpoints: (build) => ({ + getUser: build.query({ + query(id) { + return { url: `user/${id}` } + }, + }), + updateUser: build.mutation({ + query: () => '', + }), + }), + }) + + configureStore({ + reducer: { + [api.reducerPath]: api.reducer, + }, + middleware: (gDM) => gDM().concat(api.middleware), + }) + + expectTypeOf(api.reducerPath).toEqualTypeOf<'api'>() + + expectTypeOf(api.util.invalidateTags) + .parameter(0) + .toEqualTypeOf[]>() + }) + + describe('endpoint definition typings', () => { + const api = createApi({ + baseQuery: (from: 'From'): { data: 'To' } | Promise<{ data: 'To' }> => ({ + data: 'To', + }), + endpoints: () => ({}), + tagTypes: ['typeA', 'typeB'], + }) + + test('query: query & transformResponse types', () => { + api.injectEndpoints({ + endpoints: (build) => ({ + query: build.query<'RetVal', 'Arg'>({ + query: (x: 'Arg') => 'From' as const, + transformResponse(r: 'To') { + return 'RetVal' as const + }, + }), + query1: build.query<'RetVal', 'Arg'>({ + // @ts-expect-error + query: (x: 'Error') => 'From' as const, + transformResponse(r: 'To') { + return 'RetVal' as const + }, + }), + query2: build.query<'RetVal', 'Arg'>({ + // @ts-expect-error + query: (x: 'Arg') => 'Error' as const, + transformResponse(r: 'To') { + return 'RetVal' as const + }, + }), + query3: build.query<'RetVal', 'Arg'>({ + query: (x: 'Arg') => 'From' as const, + // @ts-expect-error + transformResponse(r: 'Error') { + return 'RetVal' as const + }, + }), + query4: build.query<'RetVal', 'Arg'>({ + query: (x: 'Arg') => 'From' as const, + // @ts-expect-error + transformResponse(r: 'To') { + return 'Error' as const + }, + }), + queryInference1: build.query<'RetVal', 'Arg'>({ + query: (x) => { + expectTypeOf(x).toEqualTypeOf<'Arg'>() + + return 'From' + }, + transformResponse(r) { + expectTypeOf(r).toEqualTypeOf<'To'>() + + return 'RetVal' + }, + }), + queryInference2: (() => { + const query = build.query({ + query: (x: 'Arg') => 'From' as const, + transformResponse(r: 'To') { + return 'RetVal' as const + }, + }) + + expectTypeOf(query).toMatchTypeOf< + QueryDefinition<'Arg', any, any, 'RetVal'> + >() + + return query + })(), + }), + }) + }) + + test('mutation: query & transformResponse types', () => { + api.injectEndpoints({ + endpoints: (build) => ({ + query: build.mutation<'RetVal', 'Arg'>({ + query: (x: 'Arg') => 'From' as const, + transformResponse(r: 'To') { + return 'RetVal' as const + }, + }), + query1: build.mutation<'RetVal', 'Arg'>({ + // @ts-expect-error + query: (x: 'Error') => 'From' as const, + transformResponse(r: 'To') { + return 'RetVal' as const + }, + }), + query2: build.mutation<'RetVal', 'Arg'>({ + // @ts-expect-error + query: (x: 'Arg') => 'Error' as const, + transformResponse(r: 'To') { + return 'RetVal' as const + }, + }), + query3: build.mutation<'RetVal', 'Arg'>({ + query: (x: 'Arg') => 'From' as const, + // @ts-expect-error + transformResponse(r: 'Error') { + return 'RetVal' as const + }, + }), + query4: build.mutation<'RetVal', 'Arg'>({ + query: (x: 'Arg') => 'From' as const, + // @ts-expect-error + transformResponse(r: 'To') { + return 'Error' as const + }, + }), + mutationInference1: build.mutation<'RetVal', 'Arg'>({ + query: (x) => { + expectTypeOf(x).toEqualTypeOf<'Arg'>() + + return 'From' + }, + transformResponse(r) { + expectTypeOf(r).toEqualTypeOf<'To'>() + + return 'RetVal' + }, + }), + mutationInference2: (() => { + const query = build.mutation({ + query: (x: 'Arg') => 'From' as const, + transformResponse(r: 'To') { + return 'RetVal' as const + }, + }) + + expectTypeOf(query).toMatchTypeOf< + MutationDefinition<'Arg', any, any, 'RetVal'> + >() + + return query + })(), + }), + }) + }) + + describe('enhancing endpoint definitions', () => { + const baseQuery = (x: string) => ({ data: 'success' }) + + function getNewApi() { + return createApi({ + baseQuery, + tagTypes: ['old'], + endpoints: (build) => ({ + query1: build.query<'out1', 'in1'>({ query: (id) => `${id}` }), + query2: build.query<'out2', 'in2'>({ query: (id) => `${id}` }), + mutation1: build.mutation<'out1', 'in1'>({ + query: (id) => `${id}`, + }), + mutation2: build.mutation<'out2', 'in2'>({ + query: (id) => `${id}`, + }), + }), + }) + } + + const api1 = getNewApi() + + test('warn on wrong tagType', () => { + const storeRef = setupApiStore(api1, undefined, { + withoutTestLifecycles: true, + }) + + api1.enhanceEndpoints({ + endpoints: { + query1: { + // @ts-expect-error + providesTags: ['new'], + }, + query2: { + // @ts-expect-error + providesTags: ['missing'], + }, + }, + }) + + const enhanced = api1.enhanceEndpoints({ + addTagTypes: ['new'], + endpoints: { + query1: { + providesTags: ['new'], + }, + query2: { + // @ts-expect-error + providesTags: ['missing'], + }, + }, + }) + + storeRef.store.dispatch(api1.endpoints.query1.initiate('in1')) + + storeRef.store.dispatch(api1.endpoints.query2.initiate('in2')) + + enhanced.enhanceEndpoints({ + endpoints: { + query1: { + // returned `enhanced` api contains "new" entityType + providesTags: ['new'], + }, + query2: { + // @ts-expect-error + providesTags: ['missing'], + }, + }, + }) + }) + + test('modify', () => { + const storeRef = setupApiStore(api1, undefined, { + withoutTestLifecycles: true, + }) + + api1.enhanceEndpoints({ + endpoints: { + query1: { + query: (x) => { + expectTypeOf(x).toEqualTypeOf<'in1'>() + + return 'modified1' + }, + }, + query2(definition) { + definition.query = (x) => { + expectTypeOf(x).toEqualTypeOf<'in2'>() + + return 'modified2' + } + }, + mutation1: { + query: (x) => { + expectTypeOf(x).toEqualTypeOf<'in1'>() + + return 'modified1' + }, + }, + mutation2(definition) { + definition.query = (x) => { + expectTypeOf(x).toEqualTypeOf<'in2'>() + + return 'modified2' + } + }, + // @ts-expect-error + nonExisting: {}, + }, + }) + + storeRef.store.dispatch(api1.endpoints.query1.initiate('in1')) + storeRef.store.dispatch(api1.endpoints.query2.initiate('in2')) + storeRef.store.dispatch(api1.endpoints.mutation1.initiate('in1')) + storeRef.store.dispatch(api1.endpoints.mutation2.initiate('in2')) + }) + + test('updated transform response types', async () => { + const baseApi = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), + tagTypes: ['old'], + endpoints: (build) => ({ + query1: build.query<'out1', void>({ query: () => 'success' }), + mutation1: build.mutation<'out1', void>({ query: () => 'success' }), + }), + }) + + type Transformed = { value: string } + + type Definitions = DefinitionsFromApi + + type TagTypes = TagTypesFromApi + + type Q1Definition = OverrideResultType< + Definitions['query1'], + Transformed + > + + type M1Definition = OverrideResultType< + Definitions['mutation1'], + Transformed + > + + type UpdatedDefinitions = Omit & { + query1: Q1Definition + mutation1: M1Definition + } + + const enhancedApi = baseApi.enhanceEndpoints< + TagTypes, + UpdatedDefinitions + >({ + endpoints: { + query1: { + transformResponse: (a, b, c) => ({ + value: 'transformed', + }), + }, + mutation1: { + transformResponse: (a, b, c) => ({ + value: 'transformed', + }), + }, + }, + }) + + const storeRef = setupApiStore(enhancedApi, undefined, { + withoutTestLifecycles: true, + }) + + const queryResponse = await storeRef.store.dispatch( + enhancedApi.endpoints.query1.initiate(), + ) + + expectTypeOf(queryResponse.data).toMatchTypeOf< + Transformed | undefined + >() + + const mutationResponse = await storeRef.store.dispatch( + enhancedApi.endpoints.mutation1.initiate(), + ) + + expectTypeOf(mutationResponse).toMatchTypeOf< + | { data: Transformed } + | { error: FetchBaseQueryError | SerializedError } + >() + }) + }) + }) +}) diff --git a/packages/toolkit/src/query/tests/createApi.test.ts b/packages/toolkit/src/query/tests/createApi.test.ts index 5c0f1e13f7..e1c3fad253 100644 --- a/packages/toolkit/src/query/tests/createApi.test.ts +++ b/packages/toolkit/src/query/tests/createApi.test.ts @@ -1,32 +1,20 @@ -import type { SerializedError } from '@reduxjs/toolkit' +import { server } from '@internal/query/tests/mocks/server' +import { + getSerializedHeaders, + setupApiStore, +} from '@internal/tests/utils/helpers' import { configureStore, createAction, createReducer } from '@reduxjs/toolkit' import type { - FetchBaseQueryError, + DefinitionsFromApi, FetchBaseQueryMeta, -} from '@reduxjs/toolkit/dist/query/fetchBaseQuery' -import type { - Api, - MutationDefinition, - QueryDefinition, + OverrideResultType, SerializeQueryArgs, + TagTypesFromApi, } from '@reduxjs/toolkit/query' import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query' -import type { MockInstance } from 'vitest' - -import type { - DefinitionsFromApi, - OverrideResultType, - TagTypesFromApi, -} from '@reduxjs/toolkit/dist/query/endpointDefinitions' import { HttpResponse, delay, http } from 'msw' import nodeFetch from 'node-fetch' -import { - ANY, - getSerializedHeaders, - setupApiStore, -} from '../../tests/utils/helpers' -import { expectExactType, expectType } from '../../tests/utils/typeTestHelpers' -import { server } from './mocks/server' +import type { MockInstance } from 'vitest' beforeAll(() => { vi.stubEnv('NODE_ENV', 'development') @@ -73,14 +61,6 @@ test('sensible defaults', () => { }) expect(api.reducerPath).toBe('api') - expectType<'api'>(api.reducerPath) - type TagTypes = typeof api extends Api - ? E - : 'no match' - expectType(ANY as never) - // @ts-expect-error - expectType(0) - expect(api.endpoints.getUser.name).toBe('getUser') expect(api.endpoints.updateUser.name).toBe('updateUser') }) @@ -192,7 +172,7 @@ describe('wrong tagTypes log errors', () => { if (shouldError) { expect(spy).toHaveBeenCalledWith( - "Tag type 'Users' was used, but not specified in `tagTypes`!" + "Tag type 'Users' was used, but not specified in `tagTypes`!", ) } else { expect(spy).not.toHaveBeenCalled() @@ -247,11 +227,9 @@ describe('endpoint definition typings', () => { }), queryInference1: build.query<'RetVal', 'Arg'>({ query: (x) => { - expectType<'Arg'>(x) return 'From' }, transformResponse(r) { - expectType<'To'>(r) return 'RetVal' }, }), @@ -262,7 +240,6 @@ describe('endpoint definition typings', () => { return 'RetVal' as const }, }) - expectType>(query) return query })(), }), @@ -307,11 +284,9 @@ describe('endpoint definition typings', () => { }), mutationInference1: build.mutation<'RetVal', 'Arg'>({ query: (x) => { - expectType<'Arg'>(x) return 'From' }, transformResponse(r) { - expectType<'To'>(r) return 'RetVal' }, }), @@ -322,7 +297,6 @@ describe('endpoint definition typings', () => { return 'RetVal' as const }, }) - expectType>(query) return query })(), }), @@ -466,7 +440,7 @@ describe('endpoint definition typings', () => { storeRef.store.dispatch(api.endpoints.query2.initiate('in2')) await delay(1) expect(spy).toHaveBeenCalledWith( - "Tag type 'missing' was used, but not specified in `tagTypes`!" + "Tag type 'missing' was used, but not specified in `tagTypes`!", ) // only type-test this part @@ -494,25 +468,21 @@ describe('endpoint definition typings', () => { endpoints: { query1: { query: (x) => { - expectExactType('in1' as const)(x) return 'modified1' }, }, query2(definition) { definition.query = (x) => { - expectExactType('in2' as const)(x) return 'modified2' } }, mutation1: { query: (x) => { - expectExactType('in1' as const)(x) return 'modified1' }, }, mutation2(definition) { definition.query = (x) => { - expectExactType('in2' as const)(x) return 'modified2' } }, @@ -580,17 +550,13 @@ describe('endpoint definition typings', () => { }) const queryResponse = await storeRef.store.dispatch( - enhancedApi.endpoints.query1.initiate() + enhancedApi.endpoints.query1.initiate(), ) expect(queryResponse.data).toEqual({ value: 'transformed' }) - expectType(queryResponse.data) const mutationResponse = await storeRef.store.dispatch( - enhancedApi.endpoints.mutation1.initiate() + enhancedApi.endpoints.mutation1.initiate(), ) - expectType< - { data: Transformed } | { error: FetchBaseQueryError | SerializedError } - >(mutationResponse) expect('data' in mutationResponse && mutationResponse.data).toEqual({ value: 'transformed', }) @@ -635,7 +601,7 @@ describe('additional transformResponse behaviors', () => { }), transformResponse: ( response: { body: { nested: EchoResponseData } }, - meta + meta, ) => { return { ...response.body.nested, @@ -687,7 +653,7 @@ describe('additional transformResponse behaviors', () => { test('transformResponse transforms a response from a mutation', async () => { const result = await storeRef.store.dispatch( - api.endpoints.mutation.initiate({}) + api.endpoints.mutation.initiate({}), ) expect('data' in result && result.data).toEqual({ banana: 'bread' }) @@ -695,7 +661,7 @@ describe('additional transformResponse behaviors', () => { test('transformResponse transforms a response from a mutation with an error', async () => { const result = await storeRef.store.dispatch( - api.endpoints.mutationWithError.initiate({}) + api.endpoints.mutationWithError.initiate({}), ) expect('error' in result && result.error).toEqual('error') @@ -703,7 +669,7 @@ describe('additional transformResponse behaviors', () => { test('transformResponse can inject baseQuery meta into the end result from a mutation', async () => { const result = await storeRef.store.dispatch( - api.endpoints.mutationWithMeta.initiate({}) + api.endpoints.mutationWithMeta.initiate({}), ) expect('data' in result && result.data).toEqual({ @@ -725,7 +691,7 @@ describe('additional transformResponse behaviors', () => { test('transformResponse can inject baseQuery meta into the end result from a query', async () => { const result = await storeRef.store.dispatch( - api.endpoints.queryWithMeta.initiate() + api.endpoints.queryWithMeta.initiate(), ) expect(result.data).toEqual({ @@ -797,8 +763,8 @@ describe('query endpoint lifecycles - onStart, onSuccess, onError', () => { http.get( 'https://example.com/success', () => HttpResponse.json({ value: 'failed' }, { status: 500 }), - { once: true } - ) + { once: true }, + ), ) expect(storeRef.store.getState().testReducer.count).toBe(null) @@ -809,7 +775,7 @@ describe('query endpoint lifecycles - onStart, onSuccess, onError', () => { expect(storeRef.store.getState().testReducer.count).toBe(-1) const successAttempt = storeRef.store.dispatch( - api.endpoints.query.initiate() + api.endpoints.query.initiate(), ) expect(storeRef.store.getState().testReducer.count).toBe(0) await successAttempt @@ -823,20 +789,20 @@ describe('query endpoint lifecycles - onStart, onSuccess, onError', () => { http.post( 'https://example.com/success', () => HttpResponse.json({ value: 'failed' }, { status: 500 }), - { once: true } - ) + { once: true }, + ), ) expect(storeRef.store.getState().testReducer.count).toBe(null) const failAttempt = storeRef.store.dispatch( - api.endpoints.mutation.initiate() + api.endpoints.mutation.initiate(), ) expect(storeRef.store.getState().testReducer.count).toBe(0) await failAttempt expect(storeRef.store.getState().testReducer.count).toBe(-1) const successAttempt = storeRef.store.dispatch( - api.endpoints.mutation.initiate() + api.endpoints.mutation.initiate(), ) expect(storeRef.store.getState().testReducer.count).toBe(0) await successAttempt @@ -908,7 +874,7 @@ describe('structuralSharing flag behaviors', () => { const firstRef = api.endpoints.enabled.select()(storeRef.store.getState()) await storeRef.store.dispatch( - api.endpoints.enabled.initiate(undefined, { forceRefetch: true }) + api.endpoints.enabled.initiate(undefined, { forceRefetch: true }), ) const secondRef = api.endpoints.enabled.select()(storeRef.store.getState()) @@ -922,7 +888,7 @@ describe('structuralSharing flag behaviors', () => { const firstRef = api.endpoints.disabled.select()(storeRef.store.getState()) await storeRef.store.dispatch( - api.endpoints.disabled.initiate(undefined, { forceRefetch: true }) + api.endpoints.disabled.initiate(undefined, { forceRefetch: true }), ) const secondRef = api.endpoints.disabled.select()(storeRef.store.getState()) @@ -1027,23 +993,23 @@ describe('custom serializeQueryArgs per endpoint', () => { it('Works via createApi', async () => { await storeRef.store.dispatch( - api.endpoints.queryWithNoSerializer.initiate(99) + api.endpoints.queryWithNoSerializer.initiate(99), ) expect(serializer1).toHaveBeenCalledTimes(0) await storeRef.store.dispatch( - api.endpoints.queryWithCustomSerializer.initiate(42) + api.endpoints.queryWithCustomSerializer.initiate(42), ) expect(serializer1).toHaveBeenCalled() expect( - storeRef.store.getState().api.queries['base-queryWithNoSerializer-99'] + storeRef.store.getState().api.queries['base-queryWithNoSerializer-99'], ).toBeTruthy() expect( - storeRef.store.getState().api.queries['queryWithCustomSerializer-42'] + storeRef.store.getState().api.queries['queryWithCustomSerializer-42'], ).toBeTruthy() }) @@ -1062,14 +1028,14 @@ describe('custom serializeQueryArgs per endpoint', () => { expect(serializer2).toHaveBeenCalledTimes(0) await storeRef.store.dispatch( - injectedApi.endpoints.injectedQueryWithCustomSerializer.initiate(5) + injectedApi.endpoints.injectedQueryWithCustomSerializer.initiate(5), ) expect(serializer2).toHaveBeenCalled() expect( storeRef.store.getState().api.queries[ 'injectedQueryWithCustomSerializer-5' - ] + ], ).toBeTruthy() }) @@ -1078,13 +1044,13 @@ describe('custom serializeQueryArgs per endpoint', () => { api.endpoints.queryWithCustomObjectSerializer.initiate({ id: 42, client: dummyClient, - }) + }), ) expect( storeRef.store.getState().api.queries[ 'queryWithCustomObjectSerializer({"id":42})' - ] + ], ).toBeTruthy() }) @@ -1093,13 +1059,13 @@ describe('custom serializeQueryArgs per endpoint', () => { api.endpoints.queryWithCustomNumberSerializer.initiate({ id: 42, client: dummyClient, - }) + }), ) expect( storeRef.store.getState().api.queries[ 'queryWithCustomNumberSerializer(42)' - ] + ], ).toBeTruthy() }) @@ -1115,7 +1081,7 @@ describe('custom serializeQueryArgs per endpoint', () => { const results = paginate(allItems, PAGE_SIZE, pageNum) return HttpResponse.json(results) - }) + }), ) // Page number shouldn't matter here, because the cache key ignores that. @@ -1144,7 +1110,7 @@ describe('custom serializeQueryArgs per endpoint', () => { const results = paginate(allItems, PAGE_SIZE, pageNum) return HttpResponse.json(results) - }) + }), ) const selectListItems = api.endpoints.listItems2.select(0) diff --git a/packages/toolkit/src/query/tests/errorHandling.test-d.tsx b/packages/toolkit/src/query/tests/errorHandling.test-d.tsx new file mode 100644 index 0000000000..544b12a25f --- /dev/null +++ b/packages/toolkit/src/query/tests/errorHandling.test-d.tsx @@ -0,0 +1,52 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' +import { useState } from 'react' + +const mockSuccessResponse = { value: 'success' } + +const api = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), + endpoints: (build) => ({ + update: build.mutation({ + query: () => ({ url: 'success' }), + }), + failedUpdate: build.mutation({ + query: () => ({ url: 'error' }), + }), + }), +}) + +describe('type tests', () => { + test('a mutation is unwrappable and has the correct types', () => { + function User() { + const [manualError, setManualError] = useState() + + const [update, { isLoading, data, error }] = + api.endpoints.update.useMutation() + + return ( +
+
{String(isLoading)}
+
{JSON.stringify(data)}
+
{JSON.stringify(error)}
+
+ {JSON.stringify(manualError)} +
+ +
+ ) + } + }) +}) diff --git a/packages/toolkit/src/query/tests/errorHandling.test.tsx b/packages/toolkit/src/query/tests/errorHandling.test.tsx index ae1bbbc296..04ce766fb2 100644 --- a/packages/toolkit/src/query/tests/errorHandling.test.tsx +++ b/packages/toolkit/src/query/tests/errorHandling.test.tsx @@ -1,5 +1,5 @@ import type { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit' -import type { BaseQueryFn } from '@reduxjs/toolkit/query/react' +import type { BaseQueryFn, BaseQueryApi } from '@reduxjs/toolkit/query/react' import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' import { act, @@ -14,10 +14,8 @@ import axios from 'axios' import { HttpResponse, http } from 'msw' import * as React from 'react' import { useDispatch } from 'react-redux' -import { hookWaitFor, setupApiStore } from '../../tests/utils/helpers' -import { expectExactType } from '../../tests/utils/typeTestHelpers' -import type { BaseQueryApi } from '../baseQueryTypes' -import { server } from './mocks/server' +import { hookWaitFor, setupApiStore } from '@internal/tests/utils/helpers' +import { server } from '@internal/query/tests/mocks/server' const baseQuery = fetchBaseQuery({ baseUrl: 'https://example.com' }) @@ -409,7 +407,7 @@ describe('custom axios baseQuery', () => { meta: { request: config, response: result }, } } catch (axiosError) { - let err = axiosError as AxiosError + const err = axiosError as AxiosError return { error: { status: err.response?.status, @@ -517,7 +515,6 @@ describe('error handling in a component', () => { update({ name: 'hello' }) .unwrap() .then((result) => { - expectExactType(mockSuccessResponse)(result) setManualError(undefined) }) .catch((error) => act(() => setManualError(error))) diff --git a/packages/toolkit/src/query/tests/invalidation.test.tsx b/packages/toolkit/src/query/tests/invalidation.test.tsx index 1b3ad924b9..773b7cc0a6 100644 --- a/packages/toolkit/src/query/tests/invalidation.test.tsx +++ b/packages/toolkit/src/query/tests/invalidation.test.tsx @@ -1,4 +1,4 @@ -import type { TagDescription } from '@reduxjs/toolkit/dist/query/endpointDefinitions' +import type { TagDescription } from '@reduxjs/toolkit/query' import { createApi, fakeBaseQuery } from '@reduxjs/toolkit/query' import { waitFor } from '@testing-library/react' import { delay } from 'msw' @@ -13,7 +13,7 @@ const tagTypes = [ 'dog', 'giraffe', ] as const -type TagTypes = typeof tagTypes[number] +type TagTypes = (typeof tagTypes)[number] type Tags = TagDescription[] /** providesTags, invalidatesTags, shouldInvalidate */ @@ -103,7 +103,7 @@ test.each(caseMatrix)( }), }), undefined, - { withoutTestLifecycles: true } + { withoutTestLifecycles: true }, ) store.dispatch(providing.initiate()) @@ -111,15 +111,15 @@ test.each(caseMatrix)( expect(queryCount).toBe(1) await waitFor(() => { expect(api.endpoints.providing.select()(store.getState()).status).toBe( - 'fulfilled' + 'fulfilled', ) expect(api.endpoints.unrelated.select()(store.getState()).status).toBe( - 'fulfilled' + 'fulfilled', ) }) const toInvalidate = api.util.selectInvalidatedBy( store.getState(), - invalidatesTags + invalidatesTags, ) if (shouldInvalidate) { @@ -138,5 +138,5 @@ test.each(caseMatrix)( expect(queryCount).toBe(1) await delay(2) expect(queryCount).toBe(shouldInvalidate ? 2 : 1) - } + }, ) diff --git a/packages/toolkit/src/query/tests/matchers.test-d.tsx b/packages/toolkit/src/query/tests/matchers.test-d.tsx new file mode 100644 index 0000000000..2d73df12d0 --- /dev/null +++ b/packages/toolkit/src/query/tests/matchers.test-d.tsx @@ -0,0 +1,80 @@ +import type { SerializedError } from '@reduxjs/toolkit' +import { createSlice } from '@reduxjs/toolkit' +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' + +interface ResultType { + result: 'complex' +} + +interface ArgType { + foo: 'bar' + count: 3 +} + +const baseQuery = fetchBaseQuery({ baseUrl: 'https://example.com' }) +const api = createApi({ + baseQuery, + endpoints(build) { + return { + querySuccess: build.query({ + query: () => '/success', + }), + querySuccess2: build.query({ query: () => '/success' }), + queryFail: build.query({ query: () => '/error' }), + mutationSuccess: build.mutation({ + query: () => ({ url: '/success', method: 'POST' }), + }), + mutationSuccess2: build.mutation({ + query: () => ({ url: '/success', method: 'POST' }), + }), + mutationFail: build.mutation({ + query: () => ({ url: '/error', method: 'POST' }), + }), + } + }, +}) + +describe('type tests', () => { + test('inferred types', () => { + createSlice({ + name: 'auth', + initialState: {}, + reducers: {}, + extraReducers: (builder) => { + builder + .addMatcher( + api.endpoints.querySuccess.matchPending, + (state, action) => { + expectTypeOf(action.payload).toBeUndefined() + + expectTypeOf( + action.meta.arg.originalArgs, + ).toEqualTypeOf() + }, + ) + .addMatcher( + api.endpoints.querySuccess.matchFulfilled, + (state, action) => { + expectTypeOf(action.payload).toEqualTypeOf() + + expectTypeOf(action.meta.fulfilledTimeStamp).toBeNumber() + + expectTypeOf( + action.meta.arg.originalArgs, + ).toEqualTypeOf() + }, + ) + .addMatcher( + api.endpoints.querySuccess.matchRejected, + (state, action) => { + expectTypeOf(action.error).toEqualTypeOf() + + expectTypeOf( + action.meta.arg.originalArgs, + ).toEqualTypeOf() + }, + ) + }, + }) + }) +}) diff --git a/packages/toolkit/src/query/tests/matchers.test.tsx b/packages/toolkit/src/query/tests/matchers.test.tsx index 2aad352e5c..5b26740e98 100644 --- a/packages/toolkit/src/query/tests/matchers.test.tsx +++ b/packages/toolkit/src/query/tests/matchers.test.tsx @@ -1,13 +1,11 @@ -import type { SerializedError } from '@reduxjs/toolkit' -import { createSlice } from '@reduxjs/toolkit' -import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' -import { act, renderHook } from '@testing-library/react' import { actionsReducer, hookWaitFor, setupApiStore, -} from '../../tests/utils/helpers' -import { expectExactType } from '../../tests/utils/typeTestHelpers' +} from '@internal/tests/utils/helpers' +import { createSlice } from '@reduxjs/toolkit' +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' +import { act, renderHook } from '@testing-library/react' interface ResultType { result: 'complex' @@ -65,23 +63,23 @@ test('matches query pending & fulfilled actions for the given endpoint', async ( expect(storeRef.store.getState().actions).toMatchSequence( api.internalActions.middlewareRegistered.match, endpoint.matchPending, - endpoint.matchFulfilled + endpoint.matchFulfilled, ) expect(storeRef.store.getState().actions).not.toMatchSequence( api.internalActions.middlewareRegistered.match, otherEndpoint.matchPending, - otherEndpoint.matchFulfilled + otherEndpoint.matchFulfilled, ) expect(storeRef.store.getState().actions).not.toMatchSequence( api.internalActions.middlewareRegistered.match, endpoint.matchFulfilled, api.endpoints.mutationSuccess.matchFulfilled, - endpoint.matchRejected + endpoint.matchRejected, ) expect(storeRef.store.getState().actions).not.toMatchSequence( api.internalActions.middlewareRegistered.match, endpoint.matchPending, - endpoint.matchRejected + endpoint.matchRejected, ) }) test('matches query pending & rejected actions for the given endpoint', async () => { @@ -93,17 +91,17 @@ test('matches query pending & rejected actions for the given endpoint', async () expect(storeRef.store.getState().actions).toMatchSequence( api.internalActions.middlewareRegistered.match, endpoint.matchPending, - endpoint.matchRejected + endpoint.matchRejected, ) expect(storeRef.store.getState().actions).not.toMatchSequence( api.internalActions.middlewareRegistered.match, endpoint.matchFulfilled, - endpoint.matchRejected + endpoint.matchRejected, ) expect(storeRef.store.getState().actions).not.toMatchSequence( api.internalActions.middlewareRegistered.match, endpoint.matchPending, - endpoint.matchFulfilled + endpoint.matchFulfilled, ) }) @@ -118,18 +116,18 @@ test('matches lazy query pending & fulfilled actions for given endpoint', async expect(storeRef.store.getState().actions).toMatchSequence( api.internalActions.middlewareRegistered.match, endpoint.matchPending, - endpoint.matchFulfilled + endpoint.matchFulfilled, ) expect(storeRef.store.getState().actions).not.toMatchSequence( api.internalActions.middlewareRegistered.match, endpoint.matchFulfilled, - endpoint.matchRejected + endpoint.matchRejected, ) expect(storeRef.store.getState().actions).not.toMatchSequence( api.internalActions.middlewareRegistered.match, endpoint.matchPending, - endpoint.matchRejected + endpoint.matchRejected, ) }) @@ -144,17 +142,17 @@ test('matches lazy query pending & rejected actions for given endpoint', async ( expect(storeRef.store.getState().actions).toMatchSequence( api.internalActions.middlewareRegistered.match, endpoint.matchPending, - endpoint.matchRejected + endpoint.matchRejected, ) expect(storeRef.store.getState().actions).not.toMatchSequence( api.internalActions.middlewareRegistered.match, endpoint.matchFulfilled, - endpoint.matchRejected + endpoint.matchRejected, ) expect(storeRef.store.getState().actions).not.toMatchSequence( api.internalActions.middlewareRegistered.match, endpoint.matchPending, - endpoint.matchFulfilled + endpoint.matchFulfilled, ) }) @@ -170,22 +168,22 @@ test('matches mutation pending & fulfilled actions for the given endpoint', asyn expect(storeRef.store.getState().actions).toMatchSequence( api.internalActions.middlewareRegistered.match, endpoint.matchPending, - endpoint.matchFulfilled + endpoint.matchFulfilled, ) expect(storeRef.store.getState().actions).not.toMatchSequence( api.internalActions.middlewareRegistered.match, otherEndpoint.matchPending, - otherEndpoint.matchFulfilled + otherEndpoint.matchFulfilled, ) expect(storeRef.store.getState().actions).not.toMatchSequence( api.internalActions.middlewareRegistered.match, endpoint.matchFulfilled, - endpoint.matchRejected + endpoint.matchRejected, ) expect(storeRef.store.getState().actions).not.toMatchSequence( api.internalActions.middlewareRegistered.match, endpoint.matchPending, - endpoint.matchRejected + endpoint.matchRejected, ) }) test('matches mutation pending & rejected actions for the given endpoint', async () => { @@ -199,17 +197,17 @@ test('matches mutation pending & rejected actions for the given endpoint', async expect(storeRef.store.getState().actions).toMatchSequence( api.internalActions.middlewareRegistered.match, endpoint.matchPending, - endpoint.matchRejected + endpoint.matchRejected, ) expect(storeRef.store.getState().actions).not.toMatchSequence( api.internalActions.middlewareRegistered.match, endpoint.matchFulfilled, - endpoint.matchRejected + endpoint.matchRejected, ) expect(storeRef.store.getState().actions).not.toMatchSequence( api.internalActions.middlewareRegistered.match, endpoint.matchPending, - endpoint.matchFulfilled + endpoint.matchFulfilled, ) }) @@ -223,28 +221,20 @@ test('inferred types', () => { .addMatcher( api.endpoints.querySuccess.matchPending, (state, action) => { - expectExactType(undefined)(action.payload) // @ts-expect-error console.log(action.error) - expectExactType({} as ArgType)(action.meta.arg.originalArgs) - } + }, ) .addMatcher( api.endpoints.querySuccess.matchFulfilled, (state, action) => { - expectExactType({} as ResultType)(action.payload) - expectExactType(0 as number)(action.meta.fulfilledTimeStamp) // @ts-expect-error console.log(action.error) - expectExactType({} as ArgType)(action.meta.arg.originalArgs) - } + }, ) .addMatcher( api.endpoints.querySuccess.matchRejected, - (state, action) => { - expectExactType({} as SerializedError)(action.error) - expectExactType({} as ArgType)(action.meta.arg.originalArgs) - } + (state, action) => {}, ) }, }) diff --git a/packages/toolkit/src/query/tests/queryFn.test.tsx b/packages/toolkit/src/query/tests/queryFn.test.tsx index 0114f7cff2..4b292f9983 100644 --- a/packages/toolkit/src/query/tests/queryFn.test.tsx +++ b/packages/toolkit/src/query/tests/queryFn.test.tsx @@ -1,18 +1,18 @@ +import type { QuerySubState } from '@internal/query/core/apiState' +import type { Post } from '@internal/query/tests/mocks/handlers' +import { posts } from '@internal/query/tests/mocks/handlers' +import { actionsReducer, setupApiStore } from '@internal/tests/utils/helpers' import type { SerializedError } from '@reduxjs/toolkit' import { configureStore } from '@reduxjs/toolkit' import type { BaseQueryFn, FetchBaseQueryError } from '@reduxjs/toolkit/query' import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query' -import type { Post } from './mocks/handlers' -import { posts } from './mocks/handlers' -import { actionsReducer, setupApiStore } from '../../tests/utils/helpers' -import type { QuerySubState } from '@reduxjs/toolkit/dist/query/core/apiState' describe('queryFn base implementation tests', () => { const baseQuery: BaseQueryFn = vi.fn((arg: string) => arg.includes('withErrorQuery') ? { error: `cut${arg}` } - : { data: { wrappedByBaseQuery: arg } } + : { data: { wrappedByBaseQuery: arg } }, ) const api = createApi({ @@ -193,19 +193,19 @@ describe('queryFn base implementation tests', () => { endpointName.includes('Throw') ? `An unhandled error occurred processing a request for the endpoint "${endpointName}". In the case of an unhandled error, no tags will be "provided" or "invalidated". [Error: resultFrom(${endpointName})]` - : '' + : '', ) if (expectedResult === 'data') { expect(result).toEqual( expect.objectContaining({ data: `resultFrom(${endpointName})`, - }) + }), ) } else if (expectedResult === 'error') { expect(result).toEqual( expect.objectContaining({ error: `resultFrom(${endpointName})`, - }) + }), ) } else { expect(result).toEqual( @@ -213,7 +213,7 @@ describe('queryFn base implementation tests', () => { error: expect.objectContaining({ message: `resultFrom(${endpointName})`, }), - }) + }), ) } }) @@ -241,20 +241,20 @@ describe('queryFn base implementation tests', () => { endpointName.includes('Throw') ? `An unhandled error occurred processing a request for the endpoint "${endpointName}". In the case of an unhandled error, no tags will be "provided" or "invalidated". [Error: resultFrom(${endpointName})]` - : '' + : '', ) if (expectedResult === 'data') { expect(result).toEqual( expect.objectContaining({ data: `resultFrom(${endpointName})`, - }) + }), ) } else if (expectedResult === 'error') { expect(result).toEqual( expect.objectContaining({ error: `resultFrom(${endpointName})`, - }) + }), ) } else { expect(result).toEqual( @@ -262,7 +262,7 @@ describe('queryFn base implementation tests', () => { error: expect.objectContaining({ message: `resultFrom(${endpointName})`, }), - }) + }), ) } }) @@ -275,12 +275,12 @@ describe('queryFn base implementation tests', () => { result = await store.dispatch(thunk) }).toHaveConsoleOutput( `An unhandled error occurred processing a request for the endpoint "withNeither". - In the case of an unhandled error, no tags will be "provided" or "invalidated". [TypeError: endpointDefinition.queryFn is not a function]` + In the case of an unhandled error, no tags will be "provided" or "invalidated". [TypeError: endpointDefinition.queryFn is not a function]`, ) expect(result!.error).toEqual( expect.objectContaining({ message: 'endpointDefinition.queryFn is not a function', - }) + }), ) } { @@ -293,12 +293,12 @@ describe('queryFn base implementation tests', () => { result = await store.dispatch(thunk) }).toHaveConsoleOutput( `An unhandled error occurred processing a request for the endpoint "mutationWithNeither". - In the case of an unhandled error, no tags will be "provided" or "invalidated". [TypeError: endpointDefinition.queryFn is not a function]` + In the case of an unhandled error, no tags will be "provided" or "invalidated". [TypeError: endpointDefinition.queryFn is not a function]`, ) expect((result as any).error).toEqual( expect.objectContaining({ message: 'endpointDefinition.queryFn is not a function', - }) + }), ) } }) @@ -372,14 +372,14 @@ describe('usage scenario tests', () => { it('can chain multiple queries together', async () => { const result = await storeRef.store.dispatch( - api.endpoints.getRandomUser.initiate() + api.endpoints.getRandomUser.initiate(), ) expect(result.data).toEqual(posts[1]) }) it('can wrap a service like Firebase', async () => { const result = await storeRef.store.dispatch( - api.endpoints.getFirebaseUser.initiate(1) + api.endpoints.getFirebaseUser.initiate(1), ) expect(result.data).toEqual(mockData) }) @@ -388,7 +388,7 @@ describe('usage scenario tests', () => { let result: QuerySubState await expect(async () => { result = await storeRef.store.dispatch( - api.endpoints.getMissingFirebaseUser.initiate(1) + api.endpoints.getMissingFirebaseUser.initiate(1), ) }) .toHaveConsoleOutput(`An unhandled error occurred processing a request for the endpoint "getMissingFirebaseUser". @@ -399,7 +399,7 @@ describe('usage scenario tests', () => { expect.objectContaining({ message: 'Missing user', name: 'Error', - }) + }), ) }) }) diff --git a/packages/toolkit/src/query/tests/queryLifecycle.test-d.tsx b/packages/toolkit/src/query/tests/queryLifecycle.test-d.tsx new file mode 100644 index 0000000000..983de884f4 --- /dev/null +++ b/packages/toolkit/src/query/tests/queryLifecycle.test-d.tsx @@ -0,0 +1,151 @@ +import type { + FetchBaseQueryError, + FetchBaseQueryMeta, +} from '@reduxjs/toolkit/query' +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query' + +const api = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), + endpoints: () => ({}), +}) + +describe('type tests', () => { + test(`mutation: onStart and onSuccess`, async () => { + const extended = api.injectEndpoints({ + overrideExisting: true, + endpoints: (build) => ({ + injected: build.mutation({ + query: () => '/success', + async onQueryStarted(arg, { queryFulfilled }) { + // awaiting without catching like this would result in an `unhandledRejection` exception if there was an error + // unfortunately we cannot test for that in jest. + const result = await queryFulfilled + + expectTypeOf(result).toMatchTypeOf<{ + data: number + meta?: FetchBaseQueryMeta + }>() + }, + }), + }), + }) + }) + + test('query types', () => { + const extended = api.injectEndpoints({ + overrideExisting: true, + endpoints: (build) => ({ + injected: build.query({ + query: () => '/success', + async onQueryStarted(arg, { queryFulfilled }) { + queryFulfilled.then( + (result) => { + expectTypeOf(result).toMatchTypeOf<{ + data: number + meta?: FetchBaseQueryMeta + }>() + }, + (reason) => { + if (reason.isUnhandledError) { + expectTypeOf(reason).toEqualTypeOf<{ + error: unknown + meta?: undefined + isUnhandledError: true + }>() + } else { + expectTypeOf(reason).toEqualTypeOf<{ + error: FetchBaseQueryError + isUnhandledError: false + meta: FetchBaseQueryMeta | undefined + }>() + } + }, + ) + + queryFulfilled.catch((reason) => { + if (reason.isUnhandledError) { + expectTypeOf(reason).toEqualTypeOf<{ + error: unknown + meta?: undefined + isUnhandledError: true + }>() + } else { + expectTypeOf(reason).toEqualTypeOf<{ + error: FetchBaseQueryError + isUnhandledError: false + meta: FetchBaseQueryMeta | undefined + }>() + } + }) + + const result = await queryFulfilled + + expectTypeOf(result).toMatchTypeOf<{ + data: number + meta?: FetchBaseQueryMeta + }>() + }, + }), + }), + }) + }) + + test('mutation types', () => { + const extended = api.injectEndpoints({ + overrideExisting: true, + endpoints: (build) => ({ + injected: build.query({ + query: () => '/success', + async onQueryStarted(arg, { queryFulfilled }) { + queryFulfilled.then( + (result) => { + expectTypeOf(result).toMatchTypeOf<{ + data: number + meta?: FetchBaseQueryMeta + }>() + }, + (reason) => { + if (reason.isUnhandledError) { + expectTypeOf(reason).toEqualTypeOf<{ + error: unknown + meta?: undefined + isUnhandledError: true + }>() + } else { + expectTypeOf(reason).toEqualTypeOf<{ + error: FetchBaseQueryError + isUnhandledError: false + meta: FetchBaseQueryMeta | undefined + }>() + } + }, + ) + + queryFulfilled.catch((reason) => { + if (reason.isUnhandledError) { + expectTypeOf(reason).toEqualTypeOf<{ + error: unknown + meta?: undefined + isUnhandledError: true + }>() + } else { + expectTypeOf(reason).toEqualTypeOf<{ + error: FetchBaseQueryError + isUnhandledError: false + meta: FetchBaseQueryMeta | undefined + }>() + } + }) + + const result = await queryFulfilled + + expectTypeOf(result).toMatchTypeOf<{ + data: number + meta?: FetchBaseQueryMeta + }>() + }, + }), + }), + }) + }) +}) diff --git a/packages/toolkit/src/query/tests/queryLifecycle.test.tsx b/packages/toolkit/src/query/tests/queryLifecycle.test.tsx index ffa9731400..b0396e9b0c 100644 --- a/packages/toolkit/src/query/tests/queryLifecycle.test.tsx +++ b/packages/toolkit/src/query/tests/queryLifecycle.test.tsx @@ -1,14 +1,9 @@ -import type { - FetchBaseQueryError, - FetchBaseQueryMeta, -} from '@reduxjs/toolkit/query' +import { server } from '@internal/query/tests/mocks/server' +import { setupApiStore } from '@internal/tests/utils/helpers' import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query' -import { HttpResponse, http } from 'msw' import { waitFor } from '@testing-library/react' +import { HttpResponse, http } from 'msw' import { vi } from 'vitest' -import { setupApiStore } from '../../tests/utils/helpers' -import { expectType } from '../../tests/utils/typeTestHelpers' -import { server } from './mocks/server' const api = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), @@ -56,7 +51,6 @@ describe.each([['query'], ['mutation']] as const)( // awaiting without catching like this would result in an `unhandledRejection` exception if there was an error // unfortunately we cannot test for that in jest. const result = await queryFulfilled - expectType<{ data: number; meta?: FetchBaseQueryMeta }>(result) onSuccess(result) }, }), @@ -110,7 +104,7 @@ describe.each([['query'], ['mutation']] as const)( }) expect(onSuccess).not.toHaveBeenCalled() }) - } + }, ) test('query: getCacheEntry (success)', async () => { @@ -122,7 +116,7 @@ test('query: getCacheEntry (success)', async () => { query: () => '/success', async onQueryStarted( arg, - { dispatch, getState, getCacheEntry, queryFulfilled } + { dispatch, getState, getCacheEntry, queryFulfilled }, ) { try { snapshot(getCacheEntry()) @@ -138,7 +132,7 @@ test('query: getCacheEntry (success)', async () => { }), }) const promise = storeRef.store.dispatch( - extended.endpoints.injected.initiate('arg') + extended.endpoints.injected.initiate('arg'), ) await waitFor(() => { @@ -183,7 +177,7 @@ test('query: getCacheEntry (error)', async () => { query: () => '/error', async onQueryStarted( arg, - { dispatch, getState, getCacheEntry, queryFulfilled } + { dispatch, getState, getCacheEntry, queryFulfilled }, ) { try { snapshot(getCacheEntry()) @@ -199,7 +193,7 @@ test('query: getCacheEntry (error)', async () => { }), }) const promise = storeRef.store.dispatch( - extended.endpoints.injected.initiate('arg') + extended.endpoints.injected.initiate('arg'), ) await waitFor(() => { @@ -243,7 +237,7 @@ test('mutation: getCacheEntry (success)', async () => { query: () => '/success', async onQueryStarted( arg, - { dispatch, getState, getCacheEntry, queryFulfilled } + { dispatch, getState, getCacheEntry, queryFulfilled }, ) { try { snapshot(getCacheEntry()) @@ -259,7 +253,7 @@ test('mutation: getCacheEntry (success)', async () => { }), }) const promise = storeRef.store.dispatch( - extended.endpoints.injected.initiate('arg') + extended.endpoints.injected.initiate('arg'), ) await waitFor(() => { @@ -300,7 +294,7 @@ test('mutation: getCacheEntry (error)', async () => { query: () => '/error', async onQueryStarted( arg, - { dispatch, getState, getCacheEntry, queryFulfilled } + { dispatch, getState, getCacheEntry, queryFulfilled }, ) { try { snapshot(getCacheEntry()) @@ -316,7 +310,7 @@ test('mutation: getCacheEntry (error)', async () => { }), }) const promise = storeRef.store.dispatch( - extended.endpoints.injected.initiate('arg') + extended.endpoints.injected.initiate('arg'), ) await waitFor(() => { @@ -363,7 +357,7 @@ test('query: updateCachedData', async () => { getCacheEntry, updateCachedData, queryFulfilled, - } + }, ) { // calling `updateCachedData` when there is no data yet should not do anything // but if there is a cache value it will be updated & overwritten by the next succesful result @@ -403,11 +397,11 @@ test('query: updateCachedData', async () => { () => { return HttpResponse.json({ value: 'failed' }, { status: 500 }) }, - { once: true } - ) + { once: true }, + ), ) storeRef.store.dispatch( - extended.endpoints.injected.initiate('arg', { forceRefetch: true }) + extended.endpoints.injected.initiate('arg', { forceRefetch: true }), ) await waitFor(() => { @@ -419,7 +413,7 @@ test('query: updateCachedData', async () => { expect(onSuccess).not.toHaveBeenCalled() storeRef.store.dispatch( - extended.endpoints.injected.initiate('arg', { forceRefetch: true }) + extended.endpoints.injected.initiate('arg', { forceRefetch: true }), ) await waitFor(() => { @@ -442,122 +436,14 @@ test('query: will only start lifecycle if query is not skipped due to `condition }), }) const promise = storeRef.store.dispatch( - extended.endpoints.injected.initiate('arg') + extended.endpoints.injected.initiate('arg'), ) expect(onStart).toHaveBeenCalledTimes(1) storeRef.store.dispatch(extended.endpoints.injected.initiate('arg')) expect(onStart).toHaveBeenCalledTimes(1) await promise storeRef.store.dispatch( - extended.endpoints.injected.initiate('arg', { forceRefetch: true }) + extended.endpoints.injected.initiate('arg', { forceRefetch: true }), ) expect(onStart).toHaveBeenCalledTimes(2) }) - -test('query types', () => { - const extended = api.injectEndpoints({ - overrideExisting: true, - endpoints: (build) => ({ - injected: build['query']({ - query: () => '/success', - async onQueryStarted(arg, { queryFulfilled }) { - onStart(arg) - - queryFulfilled.then( - (result) => { - expectType<{ data: number; meta?: FetchBaseQueryMeta }>(result) - }, - (reason) => { - if (reason.isUnhandledError) { - expectType<{ - error: unknown - meta?: undefined - isUnhandledError: true - }>(reason) - } else { - expectType<{ - error: FetchBaseQueryError - isUnhandledError: false - meta: FetchBaseQueryMeta | undefined - }>(reason) - } - } - ) - - queryFulfilled.catch((reason) => { - if (reason.isUnhandledError) { - expectType<{ - error: unknown - meta?: undefined - isUnhandledError: true - }>(reason) - } else { - expectType<{ - error: FetchBaseQueryError - isUnhandledError: false - meta: FetchBaseQueryMeta | undefined - }>(reason) - } - }) - - const result = await queryFulfilled - expectType<{ data: number; meta?: FetchBaseQueryMeta }>(result) - }, - }), - }), - }) -}) - -test('mutation types', () => { - const extended = api.injectEndpoints({ - overrideExisting: true, - endpoints: (build) => ({ - injected: build['query']({ - query: () => '/success', - async onQueryStarted(arg, { queryFulfilled }) { - onStart(arg) - - queryFulfilled.then( - (result) => { - expectType<{ data: number; meta?: FetchBaseQueryMeta }>(result) - }, - (reason) => { - if (reason.isUnhandledError) { - expectType<{ - error: unknown - meta?: undefined - isUnhandledError: true - }>(reason) - } else { - expectType<{ - error: FetchBaseQueryError - isUnhandledError: false - meta: FetchBaseQueryMeta | undefined - }>(reason) - } - } - ) - - queryFulfilled.catch((reason) => { - if (reason.isUnhandledError) { - expectType<{ - error: unknown - meta?: undefined - isUnhandledError: true - }>(reason) - } else { - expectType<{ - error: FetchBaseQueryError - isUnhandledError: false - meta: FetchBaseQueryMeta | undefined - }>(reason) - } - }) - - const result = await queryFulfilled - expectType<{ data: number; meta?: FetchBaseQueryMeta }>(result) - }, - }), - }), - }) -}) diff --git a/packages/toolkit/src/query/tests/retry.test-d.ts b/packages/toolkit/src/query/tests/retry.test-d.ts index 93862b41a9..b9476a13c0 100644 --- a/packages/toolkit/src/query/tests/retry.test-d.ts +++ b/packages/toolkit/src/query/tests/retry.test-d.ts @@ -1,10 +1,12 @@ -describe('RetryOptions type tests', () => { +import type { RetryOptions } from '@internal/query/retry' + +describe('type tests', () => { test('RetryOptions only accepts one of maxRetries or retryCondition', () => { - // @ts-expect-error Should complain if both exist at once - const ro: RetryOptions = { + // Should complain if both `maxRetries` and `retryCondition` exist at once + expectTypeOf().not.toMatchTypeOf({ maxRetries: 5, retryCondition: () => false, - } + }) }) }) diff --git a/packages/toolkit/src/query/tests/unionTypes.test-d.ts b/packages/toolkit/src/query/tests/unionTypes.test-d.ts index 3c25aa969d..d47cdae41b 100644 --- a/packages/toolkit/src/query/tests/unionTypes.test-d.ts +++ b/packages/toolkit/src/query/tests/unionTypes.test-d.ts @@ -35,8 +35,6 @@ describe('union types', () => { } if (result.isLoading) { - expectTypeOf(result.data).toBeNullable() - expectTypeOf(result.data).toEqualTypeOf() expectTypeOf(result.error).toEqualTypeOf< @@ -88,6 +86,7 @@ describe('union types', () => { expectTypeOf(result).toBeNever() } }) + test('useQuery union', () => { const result = api.endpoints.getTest.useQuery() @@ -136,6 +135,7 @@ describe('union types', () => { expectTypeOf(result.isFetching).toEqualTypeOf() } + if (result.isSuccess) { expectTypeOf(result.data).toBeString() @@ -168,15 +168,11 @@ describe('union types', () => { expectTypeOf(result.currentData).toEqualTypeOf() - expectTypeOf(result.currentData).not.toBeString() - if (result.isSuccess) { if (!result.isFetching) { expectTypeOf(result.currentData).toBeString() } else { expectTypeOf(result.currentData).toEqualTypeOf() - - expectTypeOf(result.currentData).not.toBeString() } } @@ -300,6 +296,7 @@ describe('union types', () => { expectTypeOf(result.isFetching).toEqualTypeOf() } + if (result.isLoading) { expectTypeOf(result.data).toBeUndefined() @@ -469,29 +466,25 @@ describe('union types', () => { test('queryHookResult (without selector) union', async () => { const useQueryStateResult = api.endpoints.getTest.useQueryState() + const useQueryResult = api.endpoints.getTest.useQuery() - const useQueryStateWithSelectFromResult = api.endpoints.getTest.useQueryState( - undefined, - { + + const useQueryStateWithSelectFromResult = + api.endpoints.getTest.useQueryState(undefined, { selectFromResult: () => ({ x: true }), - } - ) + }) const { refetch, ...useQueryResultWithoutMethods } = useQueryResult assertType(useQueryStateResult) expectTypeOf(useQueryStateResult).toMatchTypeOf( - useQueryResultWithoutMethods - ) - - expectTypeOf(useQueryStateResult).not.toEqualTypeOf( - useQueryResultWithoutMethods + useQueryResultWithoutMethods, ) expectTypeOf(useQueryStateWithSelectFromResult) .parameter(0) - .not.toEqualTypeOf(useQueryResultWithoutMethods) + .not.toMatchTypeOf(useQueryResultWithoutMethods) expectTypeOf(api.endpoints.getTest.select).returns.returns.toEqualTypeOf< Awaited> @@ -499,7 +492,6 @@ describe('union types', () => { }) test('useQueryState (with selectFromResult)', () => { - const result = api.endpoints.getTest.useQueryState(undefined, { selectFromResult({ data, @@ -782,12 +774,5 @@ describe('"Typed" helper types', () => { expectTypeOf< TypedUseMutationResult >().toMatchTypeOf(result) - - // TODO: `TypedUseMutationResult` might need a closer look since here the result is assignable to it but they are not of equal types - expectTypeOf< - TypedUseMutationResult - >().not.toEqualTypeOf(result) - - assertType>(result) }) }) diff --git a/packages/toolkit/src/tests/Tuple.test-d.ts b/packages/toolkit/src/tests/Tuple.test-d.ts new file mode 100644 index 0000000000..9433634323 --- /dev/null +++ b/packages/toolkit/src/tests/Tuple.test-d.ts @@ -0,0 +1,83 @@ +import { Tuple } from '@reduxjs/toolkit' + +describe('type tests', () => { + test('compatibility is checked between described types', () => { + const stringTuple = new Tuple('') + + expectTypeOf(stringTuple).toEqualTypeOf>() + + expectTypeOf(stringTuple).toMatchTypeOf>() + + expectTypeOf(stringTuple).not.toMatchTypeOf>() + + const numberTuple = new Tuple(0, 1) + + expectTypeOf(numberTuple).not.toMatchTypeOf>() + }) + + test('concat is inferred properly', () => { + const singleString = new Tuple('') + + expectTypeOf(singleString).toEqualTypeOf>() + + expectTypeOf(singleString.concat('')).toEqualTypeOf< + Tuple<[string, string]> + >() + + expectTypeOf(singleString.concat([''] as const)).toMatchTypeOf< + Tuple<[string, string]> + >() + }) + + test('prepend is inferred properly', () => { + const singleString = new Tuple('') + + expectTypeOf(singleString).toEqualTypeOf>() + + expectTypeOf(singleString.prepend('')).toEqualTypeOf< + Tuple<[string, string]> + >() + + expectTypeOf(singleString.prepend([''] as const)).toMatchTypeOf< + Tuple<[string, string]> + >() + }) + + test('push must match existing items', () => { + const stringTuple = new Tuple('') + + expectTypeOf(stringTuple.push).toBeCallableWith('') + + expectTypeOf(stringTuple.push).parameter(0).not.toBeNumber() + }) + + test('Tuples can be combined', () => { + const stringTuple = new Tuple('') + + const numberTuple = new Tuple(0, 1) + + expectTypeOf(stringTuple.concat(numberTuple)).toEqualTypeOf< + Tuple<[string, number, number]> + >() + + expectTypeOf(stringTuple.prepend(numberTuple)).toEqualTypeOf< + Tuple<[number, number, string]> + >() + + expectTypeOf(numberTuple.concat(stringTuple)).toEqualTypeOf< + Tuple<[number, number, string]> + >() + + expectTypeOf(numberTuple.prepend(stringTuple)).toEqualTypeOf< + Tuple<[string, number, number]> + >() + + expectTypeOf(stringTuple.prepend(numberTuple)).not.toMatchTypeOf< + Tuple<[string, number, number]> + >() + + expectTypeOf(stringTuple.concat(numberTuple)).not.toMatchTypeOf< + Tuple<[number, number, string]> + >() + }) +}) diff --git a/packages/toolkit/src/tests/Tuple.typetest.ts b/packages/toolkit/src/tests/Tuple.typetest.ts deleted file mode 100644 index 102cfd40df..0000000000 --- a/packages/toolkit/src/tests/Tuple.typetest.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Tuple } from '@reduxjs/toolkit' -import { expectType } from "./utils/typeTestHelpers" - -/** - * Test: compatibility is checked between described types - */ -{ - const stringTuple = new Tuple('') - - expectType>(stringTuple) - - expectType>(stringTuple) - - // @ts-expect-error - expectType>(stringTuple) - - const numberTuple = new Tuple(0, 1) - // @ts-expect-error - expectType>(numberTuple) -} - -/** - * Test: concat is inferred properly - */ -{ - const singleString = new Tuple('') - - expectType>(singleString) - - expectType>(singleString.concat('')) - - expectType>(singleString.concat([''])) -} - -/** - * Test: prepend is inferred properly - */ -{ - const singleString = new Tuple('') - - expectType>(singleString) - - expectType>(singleString.prepend('')) - - expectType>(singleString.prepend([''])) -} - -/** - * Test: push must match existing items - */ -{ - const stringTuple = new Tuple('') - - stringTuple.push('') - - // @ts-expect-error - stringTuple.push(0) -} - -/** - * Test: Tuples can be combined - */ -{ - const stringTuple = new Tuple('') - - const numberTuple = new Tuple(0, 1) - - expectType>(stringTuple.concat(numberTuple)) - - expectType>(stringTuple.prepend(numberTuple)) - - expectType>(numberTuple.concat(stringTuple)) - - expectType>(numberTuple.prepend(stringTuple)) - - // @ts-expect-error - expectType>(stringTuple.prepend(numberTuple)) - - // @ts-expect-error - expectType>(stringTuple.concat(numberTuple)) -} diff --git a/packages/toolkit/src/tests/combineSlices.test-d.ts b/packages/toolkit/src/tests/combineSlices.test-d.ts new file mode 100644 index 0000000000..6b773a5d54 --- /dev/null +++ b/packages/toolkit/src/tests/combineSlices.test-d.ts @@ -0,0 +1,189 @@ +import type { Reducer, Slice, WithSlice } from '@reduxjs/toolkit' +import { combineSlices } from '@reduxjs/toolkit' +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query' + +declare const stringSlice: Slice + +declare const numberSlice: Slice + +declare const booleanReducer: Reducer + +const exampleApi = createApi({ + baseQuery: fetchBaseQuery(), + endpoints: (build) => ({ + getThing: build.query({ + query: () => '', + }), + }), +}) + +type ExampleApiState = ReturnType + +describe('type tests', () => { + test('combineSlices correctly combines static state', () => { + const rootReducer = combineSlices(stringSlice, numberSlice, exampleApi, { + boolean: booleanReducer, + }) + + expectTypeOf(rootReducer(undefined, { type: '' })).toEqualTypeOf<{ + string: string + number: number + boolean: boolean + api: ExampleApiState + }>() + }) + + test('withLazyLoadedSlices adds partial to state', () => { + const rootReducer = combineSlices(stringSlice).withLazyLoadedSlices< + WithSlice & WithSlice + >() + + expectTypeOf(rootReducer(undefined, { type: '' }).number).toEqualTypeOf< + number | undefined + >() + + expectTypeOf(rootReducer(undefined, { type: '' }).api).toEqualTypeOf< + ExampleApiState | undefined + >() + }) + + test('inject marks injected keys as required', () => { + const rootReducer = combineSlices(stringSlice).withLazyLoadedSlices< + WithSlice & + WithSlice & { boolean: boolean } + >() + + expectTypeOf(rootReducer(undefined, { type: '' }).number).toEqualTypeOf< + number | undefined + >() + + expectTypeOf(rootReducer(undefined, { type: '' }).boolean).toEqualTypeOf< + boolean | undefined + >() + + expectTypeOf(rootReducer(undefined, { type: '' }).api).toEqualTypeOf< + ExampleApiState | undefined + >() + + const withNumber = rootReducer.inject(numberSlice) + + expectTypeOf(withNumber(undefined, { type: '' }).number).toBeNumber() + + const withBool = rootReducer.inject({ + reducerPath: 'boolean' as const, + reducer: booleanReducer, + }) + + expectTypeOf(withBool(undefined, { type: '' }).boolean).toBeBoolean() + + const withApi = rootReducer.inject(exampleApi) + + expectTypeOf( + withApi(undefined, { type: '' }).api, + ).toEqualTypeOf() + }) + + test('selector() allows defining selectors with injected reducers defined', () => { + const rootReducer = combineSlices(stringSlice).withLazyLoadedSlices< + WithSlice & { boolean: boolean } + >() + + type RootState = ReturnType + + const withoutInjection = rootReducer.selector( + (state: RootState) => state.number, + ) + + expectTypeOf( + withoutInjection(rootReducer(undefined, { type: '' })), + ).toEqualTypeOf() + + const withInjection = rootReducer + .inject(numberSlice) + .selector((state) => state.number) + + expectTypeOf( + withInjection(rootReducer(undefined, { type: '' })), + ).toBeNumber() + }) + + test('selector() passes arguments through', () => { + const rootReducer = combineSlices(stringSlice).withLazyLoadedSlices< + WithSlice & { boolean: boolean } + >() + + const selector = rootReducer + .inject(numberSlice) + .selector((state, num: number) => state.number) + + const state = rootReducer(undefined, { type: '' }) + + expectTypeOf(selector).toBeCallableWith(state, 0) + + // required argument + expectTypeOf(selector).parameters.not.toMatchTypeOf([state]) + + // number not string + expectTypeOf(selector).parameters.not.toMatchTypeOf([state, '']) + }) + + test('nested calls inferred correctly', () => { + const innerReducer = + combineSlices(stringSlice).withLazyLoadedSlices< + WithSlice + >() + + const innerSelector = innerReducer.inject(numberSlice).selector( + (state) => state.number, + (rootState: RootState) => rootState.inner, + ) + + const outerReducer = combineSlices({ inner: innerReducer }) + + type RootState = ReturnType + + expectTypeOf(outerReducer(undefined, { type: '' })).toMatchTypeOf<{ + inner: { string: string } + }>() + + expectTypeOf( + innerSelector(outerReducer(undefined, { type: '' })), + ).toBeNumber() + }) + + test('selector errors if selectorFn and selectState are mismatched', () => { + const combinedReducer = + combineSlices(stringSlice).withLazyLoadedSlices< + WithSlice + >() + + const outerReducer = combineSlices({ inner: combinedReducer }) + + type RootState = ReturnType + + combinedReducer.selector( + (state) => state.number, + // @ts-expect-error wrong state returned + (rootState: RootState) => rootState.inner.number, + ) + + combinedReducer.selector( + (state, num: number) => state.number, + // @ts-expect-error wrong arguments + (rootState: RootState, str: string) => rootState.inner, + ) + + combinedReducer.selector( + (state, num: number) => state.number, + (rootState: RootState) => rootState.inner, + ) + + // TODO: see if there's a way of making this work + // probably a rare case so not the end of the world if not + combinedReducer.selector( + (state) => state.number, + // @ts-ignore + (rootState: RootState, num: number) => rootState.inner, + ) + }) +}) diff --git a/packages/toolkit/src/tests/combineSlices.test.ts b/packages/toolkit/src/tests/combineSlices.test.ts index aae58662c6..a74b675c68 100644 --- a/packages/toolkit/src/tests/combineSlices.test.ts +++ b/packages/toolkit/src/tests/combineSlices.test.ts @@ -1,10 +1,10 @@ -import type { WithSlice } from '../combineSlices' -import { combineSlices } from '../combineSlices' -import { createAction } from '../createAction' -import { createReducer } from '../createReducer' -import { createSlice } from '../createSlice' -import type { CombinedState } from '../query/core/apiState' -import { expectType } from './utils/typeTestHelpers' +import type { WithSlice } from '@reduxjs/toolkit' +import { + combineSlices, + createAction, + createReducer, + createSlice, +} from '@reduxjs/toolkit' const dummyAction = createAction('dummy') @@ -26,7 +26,7 @@ const booleanReducer = createReducer(false, () => {}) const api = { reducerPath: 'api' as const, reducer: createReducer( - expectType>({ + { queries: {}, mutations: {}, provided: {}, @@ -42,8 +42,8 @@ const api = { refetchOnReconnect: false, refetchOnFocus: false, }, - }), - () => {} + }, + () => {}, ), } @@ -55,7 +55,7 @@ describe('combineSlices', () => { num: numberSlice.reducer, boolean: booleanReducer, }, - api + api, ) expect(combinedReducer(undefined, dummyAction())).toEqual({ string: stringSlice.getInitialState(), @@ -66,7 +66,6 @@ describe('combineSlices', () => { }) describe('injects', () => { beforeEach(() => { - vi.stubEnv('NODE_ENV', 'development') return vi.unstubAllEnvs @@ -83,13 +82,14 @@ describe('combineSlices', () => { const injectedReducer = combinedReducer.inject(numberSlice) expect(injectedReducer(undefined, dummyAction()).number).toBe( - numberSlice.getInitialState() + numberSlice.getInitialState(), ) }) it('logs error when same name is used for different reducers', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const combinedReducer = - combineSlices(stringSlice).withLazyLoadedSlices<{ boolean: boolean }>() + const combinedReducer = combineSlices(stringSlice).withLazyLoadedSlices<{ + boolean: boolean + }>() combinedReducer.inject({ reducerPath: 'boolean' as const, @@ -110,7 +110,7 @@ describe('combineSlices', () => { }) expect(consoleSpy).toHaveBeenCalledWith( - `called \`inject\` to override already-existing reducer boolean without specifying \`overrideExisting: true\`` + `called \`inject\` to override already-existing reducer boolean without specifying \`overrideExisting: true\``, ) consoleSpy.mockRestore() }) @@ -125,15 +125,16 @@ describe('combineSlices', () => { combinedReducer.inject( { reducerPath: 'number' as const, reducer: () => 0 }, - { overrideExisting: true } + { overrideExisting: true }, ) expect(consoleSpy).not.toHaveBeenCalled() }) }) describe('selector', () => { - const combinedReducer = - combineSlices(stringSlice).withLazyLoadedSlices<{ boolean: boolean }>() + const combinedReducer = combineSlices(stringSlice).withLazyLoadedSlices<{ + boolean: boolean + }>() const uninjectedState = combinedReducer(undefined, dummyAction()) @@ -148,18 +149,18 @@ describe('combineSlices', () => { const selectBoolean = injectedReducer.selector((state) => state.boolean) expect(selectBoolean(uninjectedState)).toBe( - booleanReducer.getInitialState() + booleanReducer.getInitialState(), ) }) it('exposes original to allow for logging', () => { const selectBoolean = injectedReducer.selector( - (state) => injectedReducer.selector.original(state).boolean + (state) => injectedReducer.selector.original(state).boolean, ) expect(selectBoolean(uninjectedState)).toBe(undefined) }) it('throws if original is called on something other than state proxy', () => { expect(() => injectedReducer.selector.original({} as any)).toThrow( - 'original must be used on state Proxy' + 'original must be used on state Proxy', ) }) it('allows passing a selectState selector, to handle nested state', () => { @@ -171,11 +172,11 @@ describe('combineSlices', () => { const selector = injectedReducer.selector( (state) => state.boolean, - (rootState: RootState) => rootState.inner + (rootState: RootState) => rootState.inner, ) expect(selector(wrappedReducer(undefined, dummyAction()))).toBe( - booleanReducer.getInitialState() + booleanReducer.getInitialState(), ) }) }) diff --git a/packages/toolkit/src/tests/combineSlices.typetest.ts b/packages/toolkit/src/tests/combineSlices.typetest.ts deleted file mode 100644 index 7b92faf380..0000000000 --- a/packages/toolkit/src/tests/combineSlices.typetest.ts +++ /dev/null @@ -1,210 +0,0 @@ -/* eslint-disable no-lone-blocks */ -import type { Reducer, Slice, WithSlice } from '@reduxjs/toolkit' -import { combineSlices } from '@reduxjs/toolkit' -import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query' -import { expectExactType, expectType } from './utils/typeTestHelpers' - -declare const stringSlice: Slice - -declare const numberSlice: Slice - -declare const booleanReducer: Reducer - -const exampleApi = createApi({ - baseQuery: fetchBaseQuery(), - endpoints: (build) => ({ - getThing: build.query({ - query: () => '', - }), - }), -}) - -type ExampleApiState = ReturnType - -/** - * Test: combineSlices correctly combines static state - */ -{ - const rootReducer = combineSlices(stringSlice, numberSlice, exampleApi, { - boolean: booleanReducer, - }) - expectType<{ - string: string - number: number - boolean: boolean - api: ExampleApiState - }>(rootReducer(undefined, { type: '' })) -} - -/** - * Test: withLazyLoadedSlices adds partial to state - */ -{ - const rootReducer = combineSlices(stringSlice).withLazyLoadedSlices< - WithSlice & WithSlice - >() - expectExactType(0)( - rootReducer(undefined, { type: '' }).number - ) - expectExactType(undefined)( - rootReducer(undefined, { type: '' }).api - ) -} - -/** - * Test: inject marks injected keys as required - */ -{ - const rootReducer = combineSlices(stringSlice).withLazyLoadedSlices< - WithSlice & - WithSlice & { boolean: boolean } - >() - - expectExactType(0)( - rootReducer(undefined, { type: '' }).number - ) - expectExactType(true)( - rootReducer(undefined, { type: '' }).boolean - ) - expectExactType(undefined)( - rootReducer(undefined, { type: '' }).api - ) - - const withNumber = rootReducer.inject(numberSlice) - expectExactType(0)(withNumber(undefined, { type: '' }).number) - - const withBool = rootReducer.inject({ - reducerPath: 'boolean' as const, - reducer: booleanReducer, - }) - expectExactType(true)(withBool(undefined, { type: '' }).boolean) - - const withApi = rootReducer.inject(exampleApi) - expectExactType({} as ExampleApiState)( - withApi(undefined, { type: '' }).api - ) -} - -declare const wrongNumberSlice: Slice - -declare const wrongBooleanReducer: Reducer - -const wrongApi = createApi({ - baseQuery: fetchBaseQuery(), - endpoints: (build) => ({ - getThing2: build.query({ - query: () => '', - }), - }), -}) - -/** - * Test: selector() allows defining selectors with injected reducers defined - */ -{ - const rootReducer = combineSlices(stringSlice).withLazyLoadedSlices< - WithSlice & { boolean: boolean } - >() - - type RootState = ReturnType - - const withoutInjection = rootReducer.selector( - (state: RootState) => state.number - ) - - expectExactType(0)( - withoutInjection(rootReducer(undefined, { type: '' })) - ) - - const withInjection = rootReducer - .inject(numberSlice) - .selector((state) => state.number) - - expectExactType(0)( - withInjection(rootReducer(undefined, { type: '' })) - ) -} - -/** - * Test: selector() passes arguments through - */ -{ - const rootReducer = combineSlices(stringSlice).withLazyLoadedSlices< - WithSlice & { boolean: boolean } - >() - - const selector = rootReducer - .inject(numberSlice) - .selector((state, num: number) => state.number) - - const state = rootReducer(undefined, { type: '' }) - // @ts-expect-error required argument - selector(state) - // @ts-expect-error number not string - selector(state, '') - selector(state, 0) -} - -/** - * Test: nested calls inferred correctly - */ -{ - const innerReducer = - combineSlices(stringSlice).withLazyLoadedSlices< - WithSlice - >() - - const innerSelector = innerReducer.inject(numberSlice).selector( - (state) => state.number, - (rootState: RootState) => rootState.inner - ) - - const outerReducer = combineSlices({ inner: innerReducer }) - - type RootState = ReturnType - - expectType<{ inner: { string: string } }>( - outerReducer(undefined, { type: '' }) - ) - - expectType(innerSelector(outerReducer(undefined, { type: '' }))) -} - -/** - * Test: selector errors if selectorFn and selectState are mismatched - */ - -{ - const combinedReducer = - combineSlices(stringSlice).withLazyLoadedSlices< - WithSlice - >() - - const outerReducer = combineSlices({ inner: combinedReducer }) - - type RootState = ReturnType - - combinedReducer.selector( - (state) => state.number, - // @ts-expect-error wrong state returned - (rootState: RootState) => rootState.inner.number - ) - combinedReducer.selector( - (state, num: number) => state.number, - // @ts-expect-error wrong arguments - (rootState: RootState, str: string) => rootState.inner - ) - - combinedReducer.selector( - (state, num: number) => state.number, - (rootState: RootState) => rootState.inner - ) - - // TODO: see if there's a way of making this work - // probably a rare case so not the end of the world if not - combinedReducer.selector( - (state) => state.number, - // @ts-ignore - (rootState: RootState, num: number) => rootState.inner - ) -} diff --git a/packages/toolkit/src/tests/configureStore.test-d.ts b/packages/toolkit/src/tests/configureStore.test-d.ts new file mode 100644 index 0000000000..2bf27f4bdc --- /dev/null +++ b/packages/toolkit/src/tests/configureStore.test-d.ts @@ -0,0 +1,805 @@ +import type { + Action, + ConfigureStoreOptions, + Dispatch, + Middleware, + PayloadAction, + Reducer, + Store, + StoreEnhancer, + ThunkAction, + ThunkDispatch, + ThunkMiddleware, + UnknownAction, +} from '@reduxjs/toolkit' +import { + Tuple, + applyMiddleware, + combineReducers, + configureStore, + createSlice, +} from '@reduxjs/toolkit' +import { thunk } from 'redux-thunk' + +const _anyMiddleware: any = () => () => () => {} + +describe('type tests', () => { + test('configureStore() requires a valid reducer or reducer map.', () => { + configureStore({ + reducer: (state, action) => 0, + }) + + configureStore({ + reducer: { + counter1: () => 0, + counter2: () => 1, + }, + }) + + // @ts-expect-error + configureStore({ reducer: 'not a reducer' }) + + // @ts-expect-error + configureStore({ reducer: { a: 'not a reducer' } }) + + // @ts-expect-error + configureStore({}) + }) + + test('configureStore() infers the store state type.', () => { + const reducer: Reducer = () => 0 + + const store = configureStore({ reducer }) + + expectTypeOf(store).toMatchTypeOf>() + + expectTypeOf(store).not.toMatchTypeOf>() + }) + + test('configureStore() infers the store action type.', () => { + const reducer: Reducer> = () => 0 + + const store = configureStore({ reducer }) + + expectTypeOf(store).toMatchTypeOf>>() + + expectTypeOf(store).not.toMatchTypeOf< + Store> + >() + }) + + test('configureStore() accepts Tuple for middleware, but not plain array.', () => { + const middleware: Middleware = (store) => (next) => next + + configureStore({ + reducer: () => 0, + middleware: () => new Tuple(middleware), + }) + + configureStore({ + reducer: () => 0, + // @ts-expect-error + middleware: () => [middleware], + }) + + configureStore({ + reducer: () => 0, + // @ts-expect-error + middleware: () => new Tuple('not middleware'), + }) + }) + + test('configureStore() accepts devTools flag.', () => { + configureStore({ + reducer: () => 0, + devTools: true, + }) + + configureStore({ + reducer: () => 0, + // @ts-expect-error + devTools: 'true', + }) + }) + + test('configureStore() accepts devTools EnhancerOptions.', () => { + configureStore({ + reducer: () => 0, + devTools: { name: 'myApp' }, + }) + + configureStore({ + reducer: () => 0, + // @ts-expect-error + devTools: { appName: 'myApp' }, + }) + }) + + test('configureStore() accepts preloadedState.', () => { + configureStore({ + reducer: () => 0, + preloadedState: 0, + }) + + configureStore({ + // @ts-expect-error + reducer: (_: number) => 0, + preloadedState: 'non-matching state type', + }) + }) + + test('nullable state is preserved', () => { + const store = configureStore({ + reducer: (): string | null => null, + }) + + expectTypeOf(store.getState()).toEqualTypeOf() + }) + + test('configureStore() accepts store Tuple for enhancers, but not plain array', () => { + const enhancer = applyMiddleware(() => (next) => next) + + const store = configureStore({ + reducer: () => 0, + enhancers: () => new Tuple(enhancer), + }) + + const store2 = configureStore({ + reducer: () => 0, + // @ts-expect-error + enhancers: () => [enhancer], + }) + + expectTypeOf(store.dispatch).toMatchTypeOf< + Dispatch & ThunkDispatch + >() + + configureStore({ + reducer: () => 0, + // @ts-expect-error + enhancers: () => new Tuple('not a store enhancer'), + }) + + const somePropertyStoreEnhancer: StoreEnhancer<{ + someProperty: string + }> = (next) => { + return (reducer, preloadedState) => { + return { + ...next(reducer, preloadedState), + someProperty: 'some value', + } + } + } + + const anotherPropertyStoreEnhancer: StoreEnhancer<{ + anotherProperty: number + }> = (next) => { + return (reducer, preloadedState) => { + return { + ...next(reducer, preloadedState), + anotherProperty: 123, + } + } + } + + const store3 = configureStore({ + reducer: () => 0, + enhancers: () => + new Tuple(somePropertyStoreEnhancer, anotherPropertyStoreEnhancer), + }) + + expectTypeOf(store3.dispatch).toEqualTypeOf() + + expectTypeOf(store3.someProperty).toBeString() + + expectTypeOf(store3.anotherProperty).toBeNumber() + + const storeWithCallback = configureStore({ + reducer: () => 0, + enhancers: (getDefaultEnhancers) => + getDefaultEnhancers() + .prepend(anotherPropertyStoreEnhancer) + .concat(somePropertyStoreEnhancer), + }) + + expectTypeOf(store3.dispatch).toMatchTypeOf< + Dispatch & ThunkDispatch + >() + + expectTypeOf(store3.someProperty).toBeString() + + expectTypeOf(store3.anotherProperty).toBeNumber() + + const someStateExtendingEnhancer: StoreEnhancer< + {}, + { someProperty: string } + > = + (next) => + (...args) => { + const store = next(...args) + const getState = () => ({ + ...store.getState(), + someProperty: 'some value', + }) + return { + ...store, + getState, + } as any + } + + const anotherStateExtendingEnhancer: StoreEnhancer< + {}, + { anotherProperty: number } + > = + (next) => + (...args) => { + const store = next(...args) + const getState = () => ({ + ...store.getState(), + anotherProperty: 123, + }) + return { + ...store, + getState, + } as any + } + + const store4 = configureStore({ + reducer: () => ({ aProperty: 0 }), + enhancers: () => + new Tuple(someStateExtendingEnhancer, anotherStateExtendingEnhancer), + }) + + const state = store4.getState() + + expectTypeOf(state.aProperty).toBeNumber() + + expectTypeOf(state.someProperty).toBeString() + + expectTypeOf(state.anotherProperty).toBeNumber() + + const storeWithCallback2 = configureStore({ + reducer: () => ({ aProperty: 0 }), + enhancers: (gDE) => + gDE().concat(someStateExtendingEnhancer, anotherStateExtendingEnhancer), + }) + + const stateWithCallback = storeWithCallback2.getState() + + expectTypeOf(stateWithCallback.aProperty).toBeNumber() + + expectTypeOf(stateWithCallback.someProperty).toBeString() + + expectTypeOf(stateWithCallback.anotherProperty).toBeNumber() + }) + + test('Preloaded state typings', () => { + const counterReducer1: Reducer = () => 0 + const counterReducer2: Reducer = () => 0 + + test('partial preloaded state', () => { + const store = configureStore({ + reducer: { + counter1: counterReducer1, + counter2: counterReducer2, + }, + preloadedState: { + counter1: 0, + }, + }) + + expectTypeOf(store.getState().counter1).toBeNumber() + + expectTypeOf(store.getState().counter2).toBeNumber() + }) + + test('empty preloaded state', () => { + const store = configureStore({ + reducer: { + counter1: counterReducer1, + counter2: counterReducer2, + }, + preloadedState: {}, + }) + + expectTypeOf(store.getState().counter1).toBeNumber() + + expectTypeOf(store.getState().counter2).toBeNumber() + }) + + test('excess properties in preloaded state', () => { + const store = configureStore({ + reducer: { + // @ts-expect-error + counter1: counterReducer1, + counter2: counterReducer2, + }, + preloadedState: { + counter1: 0, + counter3: 5, + }, + }) + + expectTypeOf(store.getState().counter1).toBeNumber() + + expectTypeOf(store.getState().counter2).toBeNumber() + }) + + test('mismatching properties in preloaded state', () => { + const store = configureStore({ + reducer: { + // @ts-expect-error + counter1: counterReducer1, + counter2: counterReducer2, + }, + preloadedState: { + counter3: 5, + }, + }) + + expectTypeOf(store.getState().counter1).toBeNumber() + + expectTypeOf(store.getState().counter2).toBeNumber() + }) + + test('string preloaded state when expecting object', () => { + const store = configureStore({ + reducer: { + // @ts-expect-error + counter1: counterReducer1, + counter2: counterReducer2, + }, + preloadedState: 'test', + }) + + expectTypeOf(store.getState().counter1).toBeNumber() + + expectTypeOf(store.getState().counter2).toBeNumber() + }) + + test('nested combineReducers allows partial', () => { + const store = configureStore({ + reducer: { + group1: combineReducers({ + counter1: counterReducer1, + counter2: counterReducer2, + }), + group2: combineReducers({ + counter1: counterReducer1, + counter2: counterReducer2, + }), + }, + preloadedState: { + group1: { + counter1: 5, + }, + }, + }) + + expectTypeOf(store.getState().group1.counter1).toBeNumber() + + expectTypeOf(store.getState().group1.counter2).toBeNumber() + + expectTypeOf(store.getState().group2.counter1).toBeNumber() + + expectTypeOf(store.getState().group2.counter2).toBeNumber() + }) + + test('non-nested combineReducers does not allow partial', () => { + interface GroupState { + counter1: number + counter2: number + } + + const initialState = { counter1: 0, counter2: 0 } + + const group1Reducer: Reducer = (state = initialState) => state + const group2Reducer: Reducer = (state = initialState) => state + + const store = configureStore({ + reducer: { + // @ts-expect-error + group1: group1Reducer, + group2: group2Reducer, + }, + preloadedState: { + group1: { + counter1: 5, + }, + }, + }) + + expectTypeOf(store.getState().group1.counter1).toBeNumber() + + expectTypeOf(store.getState().group1.counter2).toBeNumber() + + expectTypeOf(store.getState().group2.counter1).toBeNumber() + + expectTypeOf(store.getState().group2.counter2).toBeNumber() + }) + }) + + test('Dispatch typings', () => { + type StateA = number + const reducerA = () => 0 + const thunkA = () => { + return (() => {}) as any as ThunkAction, StateA, any, any> + } + + type StateB = string + const thunkB = () => { + return (dispatch: Dispatch, getState: () => StateB) => {} + } + + test('by default, dispatching Thunks is possible', () => { + const store = configureStore({ + reducer: reducerA, + }) + + store.dispatch(thunkA()) + // @ts-expect-error + store.dispatch(thunkB()) + + const res = store.dispatch((dispatch, getState) => { + return 42 + }) + + const action = store.dispatch({ type: 'foo' }) + }) + + test('return type of thunks and actions is inferred correctly', () => { + const slice = createSlice({ + name: 'counter', + initialState: { + value: 0, + }, + reducers: { + incrementByAmount: (state, action: PayloadAction) => { + state.value += action.payload + }, + }, + }) + + const store = configureStore({ + reducer: { + counter: slice.reducer, + }, + }) + + const action = slice.actions.incrementByAmount(2) + + const dispatchResult = store.dispatch(action) + + expectTypeOf(dispatchResult).toMatchTypeOf<{ + type: string + payload: number + }>() + + const promiseResult = store.dispatch(async (dispatch) => { + return 42 + }) + + expectTypeOf(promiseResult).toEqualTypeOf>() + + const store2 = configureStore({ + reducer: { + counter: slice.reducer, + }, + middleware: (gDM) => + gDM({ + thunk: { + extraArgument: 42, + }, + }), + }) + + const dispatchResult2 = store2.dispatch(action) + + expectTypeOf(dispatchResult2).toMatchTypeOf<{ + type: string + payload: number + }>() + }) + + test('removing the Thunk Middleware', () => { + const store = configureStore({ + reducer: reducerA, + middleware: () => new Tuple(), + }) + + expectTypeOf(store.dispatch).parameter(0).not.toMatchTypeOf(thunkA()) + + expectTypeOf(store.dispatch).parameter(0).not.toMatchTypeOf(thunkB()) + }) + + test('adding the thunk middleware by hand', () => { + const store = configureStore({ + reducer: reducerA, + middleware: () => new Tuple(thunk as ThunkMiddleware), + }) + + store.dispatch(thunkA()) + // @ts-expect-error + store.dispatch(thunkB()) + }) + + test('custom middleware', () => { + const store = configureStore({ + reducer: reducerA, + middleware: () => + new Tuple(0 as unknown as Middleware<(a: StateA) => boolean, StateA>), + }) + + expectTypeOf(store.dispatch(5)).toBeBoolean() + + expectTypeOf(store.dispatch(5)).not.toBeString() + }) + + test('multiple custom middleware', () => { + const middleware = [] as any as Tuple< + [ + Middleware<(a: 'a') => 'A', StateA>, + Middleware<(b: 'b') => 'B', StateA>, + ThunkMiddleware, + ] + > + + const store = configureStore({ + reducer: reducerA, + middleware: () => middleware, + }) + + expectTypeOf(store.dispatch('a')).toEqualTypeOf<'A'>() + + expectTypeOf(store.dispatch('b')).toEqualTypeOf<'B'>() + + expectTypeOf(store.dispatch(thunkA())).toEqualTypeOf>() + }) + + test('Accepts thunk with `unknown`, `undefined` or `null` ThunkAction extraArgument per default', () => { + const store = configureStore({ reducer: {} }) + // undefined is the default value for the ThunkMiddleware extraArgument + store.dispatch(function () {} as ThunkAction< + void, + {}, + undefined, + UnknownAction + >) + // `null` for the `extra` generic was previously documented in the RTK "Advanced Tutorial", but + // is a bad pattern and users should use `unknown` instead + // @ts-expect-error + store.dispatch(function () {} as ThunkAction< + void, + {}, + null, + UnknownAction + >) + // unknown is the best way to type a ThunkAction if you do not care + // about the value of the extraArgument, as it will always work with every + // ThunkMiddleware, no matter the actual extraArgument type + store.dispatch(function () {} as ThunkAction< + void, + {}, + unknown, + UnknownAction + >) + // @ts-expect-error + store.dispatch(function () {} as ThunkAction< + void, + {}, + boolean, + UnknownAction + >) + }) + + test('custom middleware and getDefaultMiddleware', () => { + const store = configureStore({ + reducer: reducerA, + middleware: (gDM) => + gDM().prepend((() => {}) as any as Middleware< + (a: 'a') => 'A', + StateA + >), + }) + + expectTypeOf(store.dispatch('a')).toEqualTypeOf<'A'>() + + expectTypeOf(store.dispatch(thunkA())).toEqualTypeOf>() + + expectTypeOf(store.dispatch).parameter(0).not.toMatchTypeOf(thunkB()) + }) + + test('custom middleware and getDefaultMiddleware, using prepend', () => { + const otherMiddleware: Middleware<(a: 'a') => 'A', StateA> = + _anyMiddleware + + const store = configureStore({ + reducer: reducerA, + middleware: (gDM) => { + const concatenated = gDM().prepend(otherMiddleware) + + expectTypeOf(concatenated).toMatchTypeOf< + ReadonlyArray< + typeof otherMiddleware | ThunkMiddleware | Middleware<{}> + > + >() + + return concatenated + }, + }) + + expectTypeOf(store.dispatch('a')).toEqualTypeOf<'A'>() + + expectTypeOf(store.dispatch(thunkA())).toEqualTypeOf>() + + expectTypeOf(store.dispatch).parameter(0).not.toMatchTypeOf(thunkB()) + }) + + test('custom middleware and getDefaultMiddleware, using concat', () => { + const otherMiddleware: Middleware<(a: 'a') => 'A', StateA> = + _anyMiddleware + + const store = configureStore({ + reducer: reducerA, + middleware: (gDM) => { + const concatenated = gDM().concat(otherMiddleware) + + expectTypeOf(concatenated).toMatchTypeOf< + ReadonlyArray< + typeof otherMiddleware | ThunkMiddleware | Middleware<{}> + > + >() + + return concatenated + }, + }) + + expectTypeOf(store.dispatch('a')).toEqualTypeOf<'A'>() + + expectTypeOf(store.dispatch(thunkA())).toEqualTypeOf>() + + expectTypeOf(store.dispatch).parameter(0).not.toMatchTypeOf(thunkB()) + }) + + test('middlewareBuilder notation, getDefaultMiddleware (unconfigured)', () => { + const store = configureStore({ + reducer: reducerA, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().prepend((() => {}) as any as Middleware< + (a: 'a') => 'A', + StateA + >), + }) + + expectTypeOf(store.dispatch('a')).toEqualTypeOf<'A'>() + + expectTypeOf(store.dispatch(thunkA())).toEqualTypeOf>() + + expectTypeOf(store.dispatch).parameter(0).not.toMatchTypeOf(thunkB()) + }) + + test('middlewareBuilder notation, getDefaultMiddleware, concat & prepend', () => { + const otherMiddleware: Middleware<(a: 'a') => 'A', StateA> = + _anyMiddleware + + const otherMiddleware2: Middleware<(a: 'b') => 'B', StateA> = + _anyMiddleware + + const store = configureStore({ + reducer: reducerA, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware() + .concat(otherMiddleware) + .prepend(otherMiddleware2), + }) + + expectTypeOf(store.dispatch('a')).toEqualTypeOf<'A'>() + + expectTypeOf(store.dispatch(thunkA())).toEqualTypeOf>() + + expectTypeOf(store.dispatch('b')).toEqualTypeOf<'B'>() + + expectTypeOf(store.dispatch).parameter(0).not.toMatchTypeOf(thunkB()) + }) + + test('middlewareBuilder notation, getDefaultMiddleware (thunk: false)', () => { + const store = configureStore({ + reducer: reducerA, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ thunk: false }).prepend( + (() => {}) as any as Middleware<(a: 'a') => 'A', StateA>, + ), + }) + + expectTypeOf(store.dispatch('a')).toEqualTypeOf<'A'>() + + expectTypeOf(store.dispatch).parameter(0).not.toMatchTypeOf(thunkA()) + }) + + test("badly typed middleware won't make `dispatch` `any`", () => { + const store = configureStore({ + reducer: reducerA, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(_anyMiddleware as Middleware), + }) + + expectTypeOf(store.dispatch).not.toBeAny() + }) + + test("decorated `configureStore` won't make `dispatch` `never`", () => { + const someSlice = createSlice({ + name: 'something', + initialState: null as any, + reducers: { + set(state) { + return state + }, + }, + }) + + function configureMyStore( + options: Omit, 'reducer'>, + ) { + return configureStore({ + ...options, + reducer: someSlice.reducer, + }) + } + + const store = configureMyStore({}) + + expectTypeOf(store.dispatch).toBeFunction() + }) + + interface CounterState { + value: number + } + + const counterSlice = createSlice({ + name: 'counter', + initialState: { value: 0 } as CounterState, + reducers: { + increment(state) { + state.value += 1 + }, + decrement(state) { + state.value -= 1 + }, + // Use the PayloadAction type to declare the contents of `action.payload` + incrementByAmount: (state, action: PayloadAction) => { + state.value += action.payload + }, + }, + }) + + type Unsubscribe = () => void + + // A fake middleware that tells TS that an unsubscribe callback is being returned for a given action + // This is the same signature that the "listener" middleware uses + const dummyMiddleware: Middleware< + { + (action: Action<'actionListenerMiddleware/add'>): Unsubscribe + }, + CounterState + > = (storeApi) => (next) => (action) => {} + + const store = configureStore({ + reducer: counterSlice.reducer, + middleware: (gDM) => gDM().prepend(dummyMiddleware), + }) + + // Order matters here! We need the listener type to come first, otherwise + // the thunk middleware type kicks in and TS thinks a plain action is being returned + expectTypeOf(store.dispatch).toEqualTypeOf< + ((action: Action<'actionListenerMiddleware/add'>) => Unsubscribe) & + ThunkDispatch & + Dispatch + >() + + const unsubscribe = store.dispatch({ + type: 'actionListenerMiddleware/add', + } as const) + + expectTypeOf(unsubscribe).toEqualTypeOf() + }) +}) diff --git a/packages/toolkit/src/tests/configureStore.typetest.ts b/packages/toolkit/src/tests/configureStore.typetest.ts deleted file mode 100644 index 1480420422..0000000000 --- a/packages/toolkit/src/tests/configureStore.typetest.ts +++ /dev/null @@ -1,837 +0,0 @@ -/* eslint-disable no-lone-blocks */ -import type { ConfigureStoreOptions, PayloadAction } from '@reduxjs/toolkit' -import { Tuple, configureStore, createSlice } from '@reduxjs/toolkit' -import type { - Action, - Dispatch, - Middleware, - Reducer, - Store, - StoreEnhancer, - UnknownAction, -} from 'redux' -import { applyMiddleware, combineReducers } from 'redux' -import type { ThunkAction, ThunkDispatch, ThunkMiddleware } from 'redux-thunk' -import { thunk } from 'redux-thunk' -import { - expectExactType, - expectNotAny, - expectType, -} from './utils/typeTestHelpers' - -const _anyMiddleware: any = () => () => () => {} - -/* - * Test: configureStore() requires a valid reducer or reducer map. - */ -{ - configureStore({ - reducer: (state, action) => 0, - }) - - configureStore({ - reducer: { - counter1: () => 0, - counter2: () => 1, - }, - }) - - // @ts-expect-error - configureStore({ reducer: 'not a reducer' }) - - // @ts-expect-error - configureStore({ reducer: { a: 'not a reducer' } }) - - // @ts-expect-error - configureStore({}) -} - -/* - * Test: configureStore() infers the store state type. - */ -{ - const reducer: Reducer = () => 0 - const store = configureStore({ reducer }) - const numberStore: Store = store - - // @ts-expect-error - const stringStore: Store = store -} - -/* - * Test: configureStore() infers the store action type. - */ -{ - const reducer: Reducer> = () => 0 - const store = configureStore({ reducer }) - const numberStore: Store> = store - - // @ts-expect-error - const stringStore: Store> = store -} - -/* - * Test: configureStore() accepts Tuple, but not plain array. - */ -{ - const middleware: Middleware = (store) => (next) => next - - configureStore({ - reducer: () => 0, - middleware: () => new Tuple(middleware), - }) - - configureStore({ - reducer: () => 0, - // @ts-expect-error - middleware: () => [middleware], - }) - - configureStore({ - reducer: () => 0, - // @ts-expect-error - middleware: () => new Tuple('not middleware'), - }) -} - -/* - * Test: configureStore() accepts devTools flag. - */ -{ - configureStore({ - reducer: () => 0, - devTools: true, - }) - - configureStore({ - reducer: () => 0, - // @ts-expect-error - devTools: 'true', - }) -} - -/* - * Test: configureStore() accepts devTools EnhancerOptions. - */ -{ - configureStore({ - reducer: () => 0, - devTools: { name: 'myApp' }, - }) - - configureStore({ - reducer: () => 0, - // @ts-expect-error - devTools: { appname: 'myApp' }, - }) -} - -/* - * Test: configureStore() accepts preloadedState. - */ -{ - configureStore({ - reducer: () => 0, - preloadedState: 0, - }) - - configureStore({ - // @ts-expect-error - reducer: (_: number) => 0, - preloadedState: 'non-matching state type', - }) -} - -/** - * Test: nullable state is preserved - */ - -{ - const store = configureStore({ - reducer: (): string | null => null, - }) - expectExactType(null)(store.getState()) -} - -/* - * Test: configureStore() accepts store Tuple, but not plain array - */ -{ - { - const enhancer = applyMiddleware(() => (next) => next) - - const store = configureStore({ - reducer: () => 0, - enhancers: () => new Tuple(enhancer), - }) - - const store2 = configureStore({ - reducer: () => 0, - // @ts-expect-error - enhancers: () => [enhancer], - }) - - expectType>( - store.dispatch - ) - } - - configureStore({ - reducer: () => 0, - // @ts-expect-error - enhancers: () => new Tuple('not a store enhancer'), - }) - - { - const somePropertyStoreEnhancer: StoreEnhancer<{ someProperty: string }> = ( - next - ) => { - return (reducer, preloadedState) => { - return { - ...next(reducer, preloadedState), - someProperty: 'some value', - } - } - } - - const anotherPropertyStoreEnhancer: StoreEnhancer<{ - anotherProperty: number - }> = (next) => { - return (reducer, preloadedState) => { - return { - ...next(reducer, preloadedState), - anotherProperty: 123, - } - } - } - - const store = configureStore({ - reducer: () => 0, - enhancers: () => - new Tuple(somePropertyStoreEnhancer, anotherPropertyStoreEnhancer), - }) - - expectType(store.dispatch) - expectType(store.someProperty) - expectType(store.anotherProperty) - - const storeWithCallback = configureStore({ - reducer: () => 0, - enhancers: (getDefaultEnhancers) => - getDefaultEnhancers() - .prepend(anotherPropertyStoreEnhancer) - .concat(somePropertyStoreEnhancer), - }) - - expectType>( - store.dispatch - ) - expectType(storeWithCallback.someProperty) - expectType(storeWithCallback.anotherProperty) - } - - { - const someStateExtendingEnhancer: StoreEnhancer< - {}, - { someProperty: string } - > = - (next) => - (...args) => { - const store = next(...args) - const getState = () => ({ - ...store.getState(), - someProperty: 'some value', - }) - return { - ...store, - getState, - } as any - } - - const anotherStateExtendingEnhancer: StoreEnhancer< - {}, - { anotherProperty: number } - > = - (next) => - (...args) => { - const store = next(...args) - const getState = () => ({ - ...store.getState(), - anotherProperty: 123, - }) - return { - ...store, - getState, - } as any - } - - const store = configureStore({ - reducer: () => ({ aProperty: 0 }), - enhancers: () => - new Tuple(someStateExtendingEnhancer, anotherStateExtendingEnhancer), - }) - - const state = store.getState() - expectType(state.aProperty) - expectType(state.someProperty) - expectType(state.anotherProperty) - - const storeWithCallback = configureStore({ - reducer: () => ({ aProperty: 0 }), - enhancers: (gDE) => - gDE().concat(someStateExtendingEnhancer, anotherStateExtendingEnhancer), - }) - - const stateWithCallback = storeWithCallback.getState() - - expectType(stateWithCallback.aProperty) - expectType(stateWithCallback.someProperty) - expectType(stateWithCallback.anotherProperty) - } -} - -/** - * Test: Preloaded state typings - */ -{ - let counterReducer1: Reducer = () => 0 - let counterReducer2: Reducer = () => 0 - - /** - * Test: partial preloaded state - */ - { - const store = configureStore({ - reducer: { - counter1: counterReducer1, - counter2: counterReducer2, - }, - preloadedState: { - counter1: 0, - }, - }) - - const counter1: number = store.getState().counter1 - const counter2: number = store.getState().counter2 - } - - /** - * Test: empty preloaded state - */ - { - const store = configureStore({ - reducer: { - counter1: counterReducer1, - counter2: counterReducer2, - }, - preloadedState: {}, - }) - - const counter1: number = store.getState().counter1 - const counter2: number = store.getState().counter2 - } - - /** - * Test: excess properties in preloaded state - */ - { - const store = configureStore({ - reducer: { - // @ts-expect-error - counter1: counterReducer1, - counter2: counterReducer2, - }, - preloadedState: { - counter1: 0, - counter3: 5, - }, - }) - - const counter1: number = store.getState().counter1 - const counter2: number = store.getState().counter2 - } - - /** - * Test: mismatching properties in preloaded state - */ - { - const store = configureStore({ - reducer: { - // @ts-expect-error - counter1: counterReducer1, - counter2: counterReducer2, - }, - preloadedState: { - counter3: 5, - }, - }) - - const counter1: number = store.getState().counter1 - const counter2: number = store.getState().counter2 - } - - /** - * Test: string preloaded state when expecting object - */ - { - const store = configureStore({ - reducer: { - // @ts-expect-error - counter1: counterReducer1, - counter2: counterReducer2, - }, - preloadedState: 'test', - }) - - const counter1: number = store.getState().counter1 - const counter2: number = store.getState().counter2 - } - - /** - * Test: nested combineReducers allows partial - */ - { - const store = configureStore({ - reducer: { - group1: combineReducers({ - counter1: counterReducer1, - counter2: counterReducer2, - }), - group2: combineReducers({ - counter1: counterReducer1, - counter2: counterReducer2, - }), - }, - preloadedState: { - group1: { - counter1: 5, - }, - }, - }) - - const group1counter1: number = store.getState().group1.counter1 - const group1counter2: number = store.getState().group1.counter2 - const group2counter1: number = store.getState().group2.counter1 - const group2counter2: number = store.getState().group2.counter2 - } - - /** - * Test: non-nested combineReducers does not allow partial - */ - { - interface GroupState { - counter1: number - counter2: number - } - - const initialState = { counter1: 0, counter2: 0 } - - const group1Reducer: Reducer = (state = initialState) => state - const group2Reducer: Reducer = (state = initialState) => state - - const store = configureStore({ - reducer: { - // @ts-expect-error - group1: group1Reducer, - group2: group2Reducer, - }, - preloadedState: { - group1: { - counter1: 5, - }, - }, - }) - - const group1counter1: number = store.getState().group1.counter1 - const group1counter2: number = store.getState().group1.counter2 - const group2counter1: number = store.getState().group2.counter1 - const group2counter2: number = store.getState().group2.counter2 - } -} - -/** - * Test: Dispatch typings - */ -{ - type StateA = number - const reducerA = () => 0 - function thunkA() { - return (() => {}) as any as ThunkAction, StateA, any, any> - } - - type StateB = string - function thunkB() { - return (dispatch: Dispatch, getState: () => StateB) => {} - } - /** - * Test: by default, dispatching Thunks is possible - */ - { - const store = configureStore({ - reducer: reducerA, - }) - - store.dispatch(thunkA()) - // @ts-expect-error - store.dispatch(thunkB()) - - const res = store.dispatch((dispatch, getState) => { - return 42 - }) - - const action = store.dispatch({ type: 'foo' }) - } - /** - * Test: return type of thunks and actions is inferred correctly - */ - { - const slice = createSlice({ - name: 'counter', - initialState: { - value: 0, - }, - reducers: { - incrementByAmount: (state, action: PayloadAction) => { - state.value += action.payload - }, - }, - }) - - const store = configureStore({ - reducer: { - counter: slice.reducer, - }, - }) - - const action = slice.actions.incrementByAmount(2) - - const dispatchResult = store.dispatch(action) - expectType<{ type: string; payload: number }>(dispatchResult) - - const promiseResult = store.dispatch(async (dispatch) => { - return 42 - }) - - expectType>(promiseResult) - - const store2 = configureStore({ - reducer: { - counter: slice.reducer, - }, - middleware: (gDM) => - gDM({ - thunk: { - extraArgument: 42, - }, - }), - }) - - const dispatchResult2 = store2.dispatch(action) - expectType<{ type: string; payload: number }>(dispatchResult2) - } - /** - * Test: removing the Thunk Middleware - */ - { - const store = configureStore({ - reducer: reducerA, - middleware: () => new Tuple(), - }) - // @ts-expect-error - store.dispatch(thunkA()) - // @ts-expect-error - store.dispatch(thunkB()) - } - /** - * Test: adding the thunk middleware by hand - */ - { - const store = configureStore({ - reducer: reducerA, - middleware: () => new Tuple(thunk as ThunkMiddleware), - }) - store.dispatch(thunkA()) - // @ts-expect-error - store.dispatch(thunkB()) - } - /** - * Test: custom middleware - */ - { - const store = configureStore({ - reducer: reducerA, - middleware: () => - new Tuple(0 as unknown as Middleware<(a: StateA) => boolean, StateA>), - }) - const result: boolean = store.dispatch(5) - // @ts-expect-error - const result2: string = store.dispatch(5) - } - /** - * Test: multiple custom middleware - */ - { - const middleware = [] as any as Tuple< - [ - Middleware<(a: 'a') => 'A', StateA>, - Middleware<(b: 'b') => 'B', StateA>, - ThunkMiddleware - ] - > - const store = configureStore({ - reducer: reducerA, - middleware: () => middleware, - }) - - const result: 'A' = store.dispatch('a') - const result2: 'B' = store.dispatch('b') - const result3: Promise<'A'> = store.dispatch(thunkA()) - } - /** - * Accepts thunk with `unknown`, `undefined` or `null` ThunkAction extraArgument per default - */ - { - const store = configureStore({ reducer: {} }) - // undefined is the default value for the ThunkMiddleware extraArgument - store.dispatch(function () {} as ThunkAction< - void, - {}, - undefined, - UnknownAction - >) - // `null` for the `extra` generic was previously documented in the RTK "Advanced Tutorial", but - // is a bad pattern and users should use `unknown` instead - // @ts-expect-error - store.dispatch(function () {} as ThunkAction) - // unknown is the best way to type a ThunkAction if you do not care - // about the value of the extraArgument, as it will always work with every - // ThunkMiddleware, no matter the actual extraArgument type - store.dispatch(function () {} as ThunkAction< - void, - {}, - unknown, - UnknownAction - >) - // @ts-expect-error - store.dispatch(function () {} as ThunkAction< - void, - {}, - boolean, - UnknownAction - >) - } - - /** - * Test: custom middleware and getDefaultMiddleware - */ - { - const store = configureStore({ - reducer: reducerA, - middleware: (gDM) => - gDM().prepend((() => {}) as any as Middleware<(a: 'a') => 'A', StateA>), - }) - - const result1: 'A' = store.dispatch('a') - const result2: Promise<'A'> = store.dispatch(thunkA()) - // @ts-expect-error - store.dispatch(thunkB()) - } - - /** - * Test: custom middleware and getDefaultMiddleware, using prepend - */ - { - const otherMiddleware: Middleware<(a: 'a') => 'A', StateA> = _anyMiddleware - - const store = configureStore({ - reducer: reducerA, - middleware: (gDM) => { - const concatenated = gDM().prepend(otherMiddleware) - expectType< - ReadonlyArray< - typeof otherMiddleware | ThunkMiddleware | Middleware<{}> - > - >(concatenated) - - return concatenated - }, - }) - const result1: 'A' = store.dispatch('a') - const result2: Promise<'A'> = store.dispatch(thunkA()) - // @ts-expect-error - store.dispatch(thunkB()) - } - - /** - * Test: custom middleware and getDefaultMiddleware, using concat - */ - { - const otherMiddleware: Middleware<(a: 'a') => 'A', StateA> = _anyMiddleware - - const store = configureStore({ - reducer: reducerA, - middleware: (gDM) => { - const concatenated = gDM().concat(otherMiddleware) - - expectType< - ReadonlyArray< - typeof otherMiddleware | ThunkMiddleware | Middleware<{}> - > - >(concatenated) - return concatenated - }, - }) - const result1: 'A' = store.dispatch('a') - const result2: Promise<'A'> = store.dispatch(thunkA()) - // @ts-expect-error - store.dispatch(thunkB()) - } - - /** - * Test: middlewareBuilder notation, getDefaultMiddleware (unconfigured) - */ - { - const store = configureStore({ - reducer: reducerA, - middleware: (getDefaultMiddleware) => - getDefaultMiddleware().prepend((() => {}) as any as Middleware< - (a: 'a') => 'A', - StateA - >), - }) - const result1: 'A' = store.dispatch('a') - const result2: Promise<'A'> = store.dispatch(thunkA()) - // @ts-expect-error - store.dispatch(thunkB()) - } - - /** - * Test: middlewareBuilder notation, getDefaultMiddleware, concat & prepend - */ - { - const otherMiddleware: Middleware<(a: 'a') => 'A', StateA> = _anyMiddleware - const otherMiddleware2: Middleware<(a: 'b') => 'B', StateA> = _anyMiddleware - const store = configureStore({ - reducer: reducerA, - middleware: (getDefaultMiddleware) => - getDefaultMiddleware() - .concat(otherMiddleware) - .prepend(otherMiddleware2), - }) - const result1: 'A' = store.dispatch('a') - const result2: Promise<'A'> = store.dispatch(thunkA()) - const result3: 'B' = store.dispatch('b') - // @ts-expect-error - store.dispatch(thunkB()) - } - - /** - * Test: middlewareBuilder notation, getDefaultMiddleware (thunk: false) - */ - { - const store = configureStore({ - reducer: reducerA, - middleware: (getDefaultMiddleware) => - getDefaultMiddleware({ thunk: false }).prepend( - (() => {}) as any as Middleware<(a: 'a') => 'A', StateA> - ), - }) - const result1: 'A' = store.dispatch('a') - // @ts-expect-error - store.dispatch(thunkA()) - } - - /** - * Test: badly typed middleware won't make `dispatch` `any` - */ - { - const store = configureStore({ - reducer: reducerA, - middleware: (getDefaultMiddleware) => - getDefaultMiddleware().concat(_anyMiddleware as Middleware), - }) - - expectNotAny(store.dispatch) - } - - /** - * Test: decorated `configureStore` won't make `dispatch` `never` - */ - { - const someSlice = createSlice({ - name: 'something', - initialState: null as any, - reducers: { - set(state) { - return state - }, - }, - }) - - function configureMyStore( - options: Omit, 'reducer'> - ) { - return configureStore({ - ...options, - reducer: someSlice.reducer, - }) - } - - const store = configureMyStore({}) - - expectType(store.dispatch) - } - - { - interface CounterState { - value: number - } - - const counterSlice = createSlice({ - name: 'counter', - initialState: { value: 0 } as CounterState, - reducers: { - increment(state) { - state.value += 1 - }, - decrement(state) { - state.value -= 1 - }, - // Use the PayloadAction type to declare the contents of `action.payload` - incrementByAmount: (state, action: PayloadAction) => { - state.value += action.payload - }, - }, - }) - - type Unsubscribe = () => void - - // A fake middleware that tells TS that an unsubscribe callback is being returned for a given action - // This is the same signature that the "listener" middleware uses - const dummyMiddleware: Middleware< - { - (action: Action<'actionListenerMiddleware/add'>): Unsubscribe - }, - CounterState - > = (storeApi) => (next) => (action) => {} - - const store = configureStore({ - reducer: counterSlice.reducer, - middleware: (gDM) => gDM().prepend(dummyMiddleware), - }) - - // Order matters here! We need the listener type to come first, otherwise - // the thunk middleware type kicks in and TS thinks a plain action is being returned - expectType< - ((action: Action<'actionListenerMiddleware/add'>) => Unsubscribe) & - ThunkDispatch & - Dispatch - >(store.dispatch) - - const unsubscribe = store.dispatch({ - type: 'actionListenerMiddleware/add', - } as const) - - expectType(unsubscribe) - } -} diff --git a/packages/toolkit/src/tests/createAction.test-d.tsx b/packages/toolkit/src/tests/createAction.test-d.tsx new file mode 100644 index 0000000000..c7af7ffab2 --- /dev/null +++ b/packages/toolkit/src/tests/createAction.test-d.tsx @@ -0,0 +1,326 @@ +import type { + Action, + ActionCreator, + ActionCreatorWithNonInferrablePayload, + ActionCreatorWithOptionalPayload, + ActionCreatorWithPayload, + ActionCreatorWithPreparedPayload, + ActionCreatorWithoutPayload, + PayloadAction, + PayloadActionCreator, + UnknownAction, +} from '@reduxjs/toolkit' +import { createAction } from '@reduxjs/toolkit' + +describe('type tests', () => { + describe('PayloadAction', () => { + test('PayloadAction has type parameter for the payload.', () => { + const action: PayloadAction = { type: '', payload: 5 } + + expectTypeOf(action.payload).toBeNumber() + + expectTypeOf(action.payload).not.toBeString() + }) + + test('PayloadAction type parameter is required.', () => { + expectTypeOf({ type: '', payload: 5 }).not.toMatchTypeOf() + }) + + test('PayloadAction has a string type tag.', () => { + expectTypeOf({ type: '', payload: 5 }).toEqualTypeOf< + PayloadAction + >() + + expectTypeOf({ type: 1, payload: 5 }).not.toMatchTypeOf() + }) + + test('PayloadAction is compatible with Action', () => { + const action: PayloadAction = { type: '', payload: 5 } + + expectTypeOf(action).toMatchTypeOf>() + }) + }) + + describe('PayloadActionCreator', () => { + test('PayloadActionCreator returns correctly typed PayloadAction depending on whether a payload is passed.', () => { + const actionCreator = Object.assign( + (payload?: number) => ({ + type: 'action', + payload, + }), + { type: 'action' }, + ) as PayloadActionCreator + + expectTypeOf(actionCreator(1)).toEqualTypeOf< + PayloadAction + >() + + expectTypeOf(actionCreator()).toEqualTypeOf< + PayloadAction + >() + + expectTypeOf(actionCreator(undefined)).toEqualTypeOf< + PayloadAction + >() + + expectTypeOf(actionCreator()).not.toMatchTypeOf>() + + expectTypeOf(actionCreator(1)).not.toMatchTypeOf< + PayloadAction + >() + }) + + test('PayloadActionCreator is compatible with ActionCreator.', () => { + const payloadActionCreator = Object.assign( + (payload?: number) => ({ + type: 'action', + payload, + }), + { type: 'action' }, + ) as PayloadActionCreator + + expectTypeOf(payloadActionCreator).toMatchTypeOf< + ActionCreator + >() + + const payloadActionCreator2 = Object.assign( + (payload?: number) => ({ + type: 'action', + payload: payload || 1, + }), + { type: 'action' }, + ) as PayloadActionCreator + + expectTypeOf(payloadActionCreator2).toMatchTypeOf< + ActionCreator> + >() + }) + }) + + test('createAction() has type parameter for the action payload.', () => { + const increment = createAction('increment') + + expectTypeOf(increment).parameter(0).toBeNumber() + + expectTypeOf(increment).parameter(0).not.toBeString() + }) + + test('createAction() type parameter is required, not inferred (defaults to `void`).', () => { + const increment = createAction('increment') + + expectTypeOf(increment).parameter(0).not.toBeNumber() + + expectTypeOf(increment().payload).not.toBeNumber() + }) + + test('createAction().type is a string literal.', () => { + const increment = createAction('increment') + + expectTypeOf(increment(1).type).toBeString() + + expectTypeOf(increment(1).type).toEqualTypeOf<'increment'>() + + expectTypeOf(increment(1).type).not.toMatchTypeOf<'other'>() + + expectTypeOf(increment(1).type).not.toBeNumber() + }) + + test('type still present when using prepareAction', () => { + const strLenAction = createAction('strLen', (payload: string) => ({ + payload: payload.length, + })) + + expectTypeOf(strLenAction('test').type).toBeString() + }) + + test('changing payload type with prepareAction', () => { + const strLenAction = createAction('strLen', (payload: string) => ({ + payload: payload.length, + })) + + expectTypeOf(strLenAction('test').payload).toBeNumber() + + expectTypeOf(strLenAction('test').payload).not.toBeString() + + expectTypeOf(strLenAction('test')).not.toHaveProperty('error') + }) + + test('adding metadata with prepareAction', () => { + const strLenMetaAction = createAction('strLenMeta', (payload: string) => ({ + payload, + meta: payload.length, + })) + + expectTypeOf(strLenMetaAction('test').meta).toBeNumber() + + expectTypeOf(strLenMetaAction('test').meta).not.toBeString() + + expectTypeOf(strLenMetaAction('test')).not.toHaveProperty('error') + }) + + test('adding boolean error with prepareAction', () => { + const boolErrorAction = createAction('boolError', (payload: string) => ({ + payload, + error: true, + })) + + expectTypeOf(boolErrorAction('test').error).toBeBoolean() + + expectTypeOf(boolErrorAction('test').error).not.toBeString() + }) + + test('adding string error with prepareAction', () => { + const strErrorAction = createAction('strError', (payload: string) => ({ + payload, + error: 'this is an error', + })) + + expectTypeOf(strErrorAction('test').error).toBeString() + + expectTypeOf(strErrorAction('test').error).not.toBeBoolean() + }) + + test('regression test for https://github.com/reduxjs/redux-toolkit/issues/214', () => { + const action = createAction<{ input?: string }>('ACTION') + + expectTypeOf(action({ input: '' }).payload.input).toEqualTypeOf< + string | undefined + >() + + expectTypeOf(action({ input: '' }).payload.input).not.toBeNumber() + + expectTypeOf(action).parameter(0).not.toMatchTypeOf({ input: 3 }) + }) + + test('regression test for https://github.com/reduxjs/redux-toolkit/issues/224', () => { + const oops = createAction('oops', (x: any) => ({ + payload: x, + error: x, + meta: x, + })) + + expectTypeOf(oops('').payload).toBeAny() + + expectTypeOf(oops('').error).toBeAny() + + expectTypeOf(oops('').meta).toBeAny() + }) + + describe('createAction.match()', () => { + test('simple use case', () => { + const actionCreator = createAction('test') + + const x: Action = {} as any + + if (actionCreator.match(x)) { + expectTypeOf(x.type).toEqualTypeOf<'test'>() + + expectTypeOf(x.payload).toBeString() + } else { + expectTypeOf(x.type).not.toMatchTypeOf<'test'>() + + expectTypeOf(x).not.toHaveProperty('payload') + } + }) + + test('special case: optional argument', () => { + const actionCreator = createAction('test') + + const x: Action = {} as any + + if (actionCreator.match(x)) { + expectTypeOf(x.type).toEqualTypeOf<'test'>() + + expectTypeOf(x.payload).toEqualTypeOf() + } + }) + + test('special case: without argument', () => { + const actionCreator = createAction('test') + + const x: Action = {} as any + + if (actionCreator.match(x)) { + expectTypeOf(x.type).toEqualTypeOf<'test'>() + + expectTypeOf(x.payload).not.toMatchTypeOf<{}>() + } + }) + + test('special case: with prepareAction', () => { + const actionCreator = createAction('test', () => ({ + payload: '', + meta: '', + error: false, + })) + + const x: Action = {} as any + + if (actionCreator.match(x)) { + expectTypeOf(x.type).toEqualTypeOf<'test'>() + + expectTypeOf(x.payload).toBeString() + + expectTypeOf(x.meta).toBeString() + + expectTypeOf(x.error).toBeBoolean() + + expectTypeOf(x.payload).not.toBeNumber() + + expectTypeOf(x.meta).not.toBeNumber() + + expectTypeOf(x.error).not.toBeNumber() + } + }) + test('potential use: as array filter', () => { + const actionCreator = createAction('test') + + const x: Action[] = [] + + expectTypeOf(x.filter(actionCreator.match)).toEqualTypeOf< + PayloadAction[] + >() + }) + }) + + test('ActionCreatorWithOptionalPayload', () => { + expectTypeOf(createAction('')).toEqualTypeOf< + ActionCreatorWithOptionalPayload + >() + + expectTypeOf( + createAction(''), + ).toEqualTypeOf() + + assertType(createAction('')) + + expectTypeOf(createAction('')).toEqualTypeOf< + ActionCreatorWithPayload + >() + + expectTypeOf( + createAction('', (_: 0) => ({ + payload: 1 as 1, + error: 2 as 2, + meta: 3 as 3, + })), + ).toEqualTypeOf>() + + const anyCreator = createAction('') + + expectTypeOf(anyCreator).toEqualTypeOf>() + + expectTypeOf(anyCreator({}).payload).toBeAny() + }) + + test("Verify action creators should not be passed directly as arguments to React event handlers if there shouldn't be a payload", () => { + const emptyAction = createAction('empty/action') + + function TestComponent() { + // This typically leads to an error like: + // // A non-serializable value was detected in an action, in the path: `payload`. + // @ts-expect-error Should error because `void` and `MouseEvent` aren't compatible + return + } + }) +}) diff --git a/packages/toolkit/src/tests/createAction.typetest.tsx b/packages/toolkit/src/tests/createAction.typetest.tsx deleted file mode 100644 index a6644d8312..0000000000 --- a/packages/toolkit/src/tests/createAction.typetest.tsx +++ /dev/null @@ -1,358 +0,0 @@ -import type { IsAny } from '@internal/tsHelpers' -import type { - ActionCreatorWithNonInferrablePayload, - ActionCreatorWithOptionalPayload, - ActionCreatorWithPayload, - ActionCreatorWithPreparedPayload, - ActionCreatorWithoutPayload, - PayloadAction, - PayloadActionCreator, -} from '@reduxjs/toolkit' -import { createAction } from '@reduxjs/toolkit' -import type { Action, ActionCreator, UnknownAction } from 'redux' -import { expectType } from './utils/typeTestHelpers' - -/* PayloadAction */ - -/* - * Test: PayloadAction has type parameter for the payload. - */ -{ - const action: PayloadAction = { type: '', payload: 5 } - const numberPayload: number = action.payload - - // @ts-expect-error - const stringPayload: string = action.payload -} - -/* - * Test: PayloadAction type parameter is required. - */ -{ - // @ts-expect-error - const action: PayloadAction = { type: '', payload: 5 } - // @ts-expect-error - const numberPayload: number = action.payload - // @ts-expect-error - const stringPayload: string = action.payload -} - -/* - * Test: PayloadAction has a string type tag. - */ -{ - const action: PayloadAction = { type: '', payload: 5 } - - // @ts-expect-error - const action2: PayloadAction = { type: 1, payload: 5 } -} - -/* - * Test: PayloadAction is compatible with Action - */ -{ - const action: PayloadAction = { type: '', payload: 5 } - const stringAction: Action = action -} - -/* PayloadActionCreator */ - -/* - * Test: PayloadActionCreator returns correctly typed PayloadAction depending - * on whether a payload is passed. - */ -{ - const actionCreator = Object.assign( - (payload?: number) => ({ - type: 'action', - payload, - }), - { type: 'action' } - ) as PayloadActionCreator - - expectType>(actionCreator(1)) - expectType>(actionCreator()) - expectType>(actionCreator(undefined)) - - // @ts-expect-error - expectType>(actionCreator()) - // @ts-expect-error - expectType>(actionCreator(1)) -} - -/* - * Test: PayloadActionCreator is compatible with ActionCreator. - */ -{ - const payloadActionCreator = Object.assign( - (payload?: number) => ({ - type: 'action', - payload, - }), - { type: 'action' } - ) as PayloadActionCreator - const actionCreator: ActionCreator = payloadActionCreator - - const payloadActionCreator2 = Object.assign( - (payload?: number) => ({ - type: 'action', - payload: payload || 1, - }), - { type: 'action' } - ) as PayloadActionCreator - - const actionCreator2: ActionCreator> = - payloadActionCreator2 -} - -/* createAction() */ - -/* - * Test: createAction() has type parameter for the action payload. - */ -{ - const increment = createAction('increment') - const n: number = increment(1).payload - - // @ts-expect-error - increment('').payload -} - -/* - * Test: createAction() type parameter is required, not inferred (defaults to `void`). - */ -{ - const increment = createAction('increment') - // @ts-expect-error - const n: number = increment(1).payload -} -/* - * Test: createAction().type is a string literal. - */ -{ - const increment = createAction('increment') - const n: string = increment(1).type - const s: 'increment' = increment(1).type - - // @ts-expect-error - const r: 'other' = increment(1).type - // @ts-expect-error - const q: number = increment(1).type -} - -/* - * Test: type still present when using prepareAction - */ -{ - const strLenAction = createAction('strLen', (payload: string) => ({ - payload: payload.length, - })) - - expectType(strLenAction('test').type) -} - -/* - * Test: changing payload type with prepareAction - */ -{ - const strLenAction = createAction('strLen', (payload: string) => ({ - payload: payload.length, - })) - expectType(strLenAction('test').payload) - - // @ts-expect-error - expectType(strLenAction('test').payload) - // @ts-expect-error - const error: any = strLenAction('test').error -} - -/* - * Test: adding metadata with prepareAction - */ -{ - const strLenMetaAction = createAction('strLenMeta', (payload: string) => ({ - payload, - meta: payload.length, - })) - - expectType(strLenMetaAction('test').meta) - - // @ts-expect-error - expectType(strLenMetaAction('test').meta) - // @ts-expect-error - const error: any = strLenMetaAction('test').error -} - -/* - * Test: adding boolean error with prepareAction - */ -{ - const boolErrorAction = createAction('boolError', (payload: string) => ({ - payload, - error: true, - })) - - expectType(boolErrorAction('test').error) - - // @ts-expect-error - expectType(boolErrorAction('test').error) -} - -/* - * Test: adding string error with prepareAction - */ -{ - const strErrorAction = createAction('strError', (payload: string) => ({ - payload, - error: 'this is an error', - })) - - expectType(strErrorAction('test').error) - - // @ts-expect-error - expectType(strErrorAction('test').error) -} - -/* - * regression test for https://github.com/reduxjs/redux-toolkit/issues/214 - */ -{ - const action = createAction<{ input?: string }>('ACTION') - const t: string | undefined = action({ input: '' }).payload.input - - // @ts-expect-error - const u: number = action({ input: '' }).payload.input - // @ts-expect-error - const v: number = action({ input: 3 }).payload.input -} - -/* - * regression test for https://github.com/reduxjs/redux-toolkit/issues/224 - */ -{ - const oops = createAction('oops', (x: any) => ({ - payload: x, - error: x, - meta: x, - })) - - type Ret = ReturnType - - const payload: IsAny = true - const error: IsAny = true - const meta: IsAny = true - - // @ts-expect-error - const payloadNotAny: IsAny = false - // @ts-expect-error - const errorNotAny: IsAny = false - // @ts-expect-error - const metaNotAny: IsAny = false -} - -/** - * Test: createAction.match() - */ -{ - // simple use case - { - const actionCreator = createAction('test') - const x: Action = {} as any - if (actionCreator.match(x)) { - expectType<'test'>(x.type) - expectType(x.payload) - } else { - // @ts-expect-error - expectType<'test'>(x.type) - // @ts-expect-error - expectType(x.payload) - } - } - - // special case: optional argument - { - const actionCreator = createAction('test') - const x: Action = {} as any - if (actionCreator.match(x)) { - expectType<'test'>(x.type) - expectType(x.payload) - } - } - - // special case: without argument - { - const actionCreator = createAction('test') - const x: Action = {} as any - if (actionCreator.match(x)) { - expectType<'test'>(x.type) - // @ts-expect-error - expectType<{}>(x.payload) - } - } - - // special case: with prepareAction - { - const actionCreator = createAction('test', () => ({ - payload: '', - meta: '', - error: false, - })) - const x: Action = {} as any - if (actionCreator.match(x)) { - expectType<'test'>(x.type) - expectType(x.payload) - expectType(x.meta) - expectType(x.error) - // @ts-expect-error - expectType(x.payload) - // @ts-expect-error - expectType(x.meta) - // @ts-expect-error - expectType(x.error) - } - } - // potential use: as array filter - { - const actionCreator = createAction('test') - const x: Array> = [] - expectType>>( - x.filter(actionCreator.match) - ) - - expectType>>( - // @ts-expect-error - x.filter(actionCreator.match) - ) - } -} -{ - expectType>( - createAction('') - ) - expectType(createAction('')) - expectType(createAction('')) - expectType>(createAction('')) - expectType>( - createAction('', (_: 0) => ({ - payload: 1 as 1, - error: 2 as 2, - meta: 3 as 3, - })) - ) - const anyCreator = createAction('') - expectType>(anyCreator) - type AnyPayload = ReturnType['payload'] - expectType>(true) -} - -// Verify action creators should not be passed directly as arguments -// to React event handlers if there shouldn't be a payload -{ - const emptyAction = createAction('empty/action') - function TestComponent() { - // This typically leads to an error like: - // // A non-serializable value was detected in an action, in the path: `payload`. - // @ts-expect-error Should error because `void` and `MouseEvent` aren't compatible - return - } -} diff --git a/packages/toolkit/src/tests/createAsyncThunk.test-d.ts b/packages/toolkit/src/tests/createAsyncThunk.test-d.ts new file mode 100644 index 0000000000..7f961666e9 --- /dev/null +++ b/packages/toolkit/src/tests/createAsyncThunk.test-d.ts @@ -0,0 +1,891 @@ +import type { + AsyncThunk, + SerializedError, + ThunkDispatch, + UnknownAction, +} from '@reduxjs/toolkit' +import { + configureStore, + createAsyncThunk, + createReducer, + createSlice, + unwrapResult, +} from '@reduxjs/toolkit' + +import type { TSVersion } from '@phryneas/ts-version' +import type { AxiosError } from 'axios' +import apiRequest from 'axios' + +const defaultDispatch = (() => {}) as ThunkDispatch<{}, any, UnknownAction> +const unknownAction = { type: 'foo' } as UnknownAction + +describe('type tests', () => { + test('basic usage', async () => { + const asyncThunk = createAsyncThunk('test', (id: number) => + Promise.resolve(id * 2), + ) + + const reducer = createReducer({}, (builder) => + builder + .addCase(asyncThunk.pending, (_, action) => { + expectTypeOf(action).toEqualTypeOf< + ReturnType<(typeof asyncThunk)['pending']> + >() + }) + + .addCase(asyncThunk.fulfilled, (_, action) => { + expectTypeOf(action).toEqualTypeOf< + ReturnType<(typeof asyncThunk)['fulfilled']> + >() + + expectTypeOf(action.payload).toBeNumber() + }) + + .addCase(asyncThunk.rejected, (_, action) => { + expectTypeOf(action).toEqualTypeOf< + ReturnType<(typeof asyncThunk)['rejected']> + >() + + expectTypeOf(action.error).toMatchTypeOf | undefined>() + }), + ) + + const promise = defaultDispatch(asyncThunk(3)) + + expectTypeOf(promise.requestId).toBeString() + + expectTypeOf(promise.arg).toBeNumber() + + expectTypeOf(promise.abort).toEqualTypeOf<(reason?: string) => void>() + + const result = await promise + + if (asyncThunk.fulfilled.match(result)) { + expectTypeOf(result).toEqualTypeOf< + ReturnType<(typeof asyncThunk)['fulfilled']> + >() + } else { + expectTypeOf(result).toEqualTypeOf< + ReturnType<(typeof asyncThunk)['rejected']> + >() + } + + promise + .then(unwrapResult) + .then((result) => { + expectTypeOf(result).toBeNumber() + + expectTypeOf(result).not.toMatchTypeOf() + }) + .catch((error) => { + // catch is always any-typed, nothing we can do here + expectTypeOf(error).toBeAny() + }) + }) + + test('More complex usage of thunk args', () => { + interface BookModel { + id: string + title: string + } + + type BooksState = BookModel[] + + const fakeBooks: BookModel[] = [ + { id: 'b', title: 'Second' }, + { id: 'a', title: 'First' }, + ] + + const correctDispatch = (() => {}) as ThunkDispatch< + BookModel[], + { userAPI: Function }, + UnknownAction + > + + // Verify that the the first type args to createAsyncThunk line up right + const fetchBooksTAC = createAsyncThunk< + BookModel[], + number, + { + state: BooksState + extra: { userAPI: Function } + } + >( + 'books/fetch', + async (arg, { getState, dispatch, extra, requestId, signal }) => { + const state = getState() + + expectTypeOf(arg).toBeNumber() + + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(extra).toEqualTypeOf<{ userAPI: Function }>() + + return fakeBooks + }, + ) + + correctDispatch(fetchBooksTAC(1)) + // @ts-expect-error + defaultDispatch(fetchBooksTAC(1)) + }) + + test('returning a rejected action from the promise creator is possible', async () => { + type ReturnValue = { data: 'success' } + type RejectValue = { data: 'error' } + + const fetchBooksTAC = createAsyncThunk< + ReturnValue, + number, + { + rejectValue: RejectValue + } + >('books/fetch', async (arg, { rejectWithValue }) => { + return rejectWithValue({ data: 'error' }) + }) + + const returned = await defaultDispatch(fetchBooksTAC(1)) + if (fetchBooksTAC.rejected.match(returned)) { + expectTypeOf(returned.payload).toEqualTypeOf() + + expectTypeOf(returned.payload).toBeNullable() + } else { + expectTypeOf(returned.payload).toEqualTypeOf() + } + + expectTypeOf(unwrapResult(returned)).toEqualTypeOf() + + expectTypeOf(unwrapResult(returned)).not.toMatchTypeOf() + }) + + test('regression #1156: union return values fall back to allowing only single member', () => { + const fn = createAsyncThunk('session/isAdmin', async () => { + const response: boolean = false + return response + }) + }) + + test('Should handle reject with value within a try catch block. Note: this is a sample code taken from #1605', () => { + type ResultType = { + text: string + } + const demoPromise = async (): Promise => + new Promise((resolve, _) => resolve({ text: '' })) + const thunk = createAsyncThunk('thunk', async (args, thunkAPI) => { + try { + const result = await demoPromise() + return result + } catch (error) { + return thunkAPI.rejectWithValue(error) + } + }) + createReducer({}, (builder) => + builder.addCase(thunk.fulfilled, (s, action) => { + expectTypeOf(action.payload).toEqualTypeOf() + }), + ) + }) + + test('reject with value', () => { + interface Item { + name: string + } + + interface ErrorFromServer { + error: string + } + + interface CallsResponse { + data: Item[] + } + + const fetchLiveCallsError = createAsyncThunk< + Item[], + string, + { + rejectValue: ErrorFromServer + } + >('calls/fetchLiveCalls', async (organizationId, { rejectWithValue }) => { + try { + const result = await apiRequest.get( + `organizations/${organizationId}/calls/live/iwill404`, + ) + return result.data.data + } catch (err) { + const error: AxiosError = err as any // cast for access to AxiosError properties + if (!error.response) { + // let it be handled as any other unknown error + throw err + } + return rejectWithValue(error.response && error.response.data) + } + }) + + defaultDispatch(fetchLiveCallsError('asd')).then((result) => { + if (fetchLiveCallsError.fulfilled.match(result)) { + //success + expectTypeOf(result).toEqualTypeOf< + ReturnType<(typeof fetchLiveCallsError)['fulfilled']> + >() + + expectTypeOf(result.payload).toEqualTypeOf() + } else { + expectTypeOf(result).toEqualTypeOf< + ReturnType<(typeof fetchLiveCallsError)['rejected']> + >() + + if (result.payload) { + // rejected with value + expectTypeOf(result.payload).toEqualTypeOf() + } else { + // rejected by throw + expectTypeOf(result.payload).toBeUndefined() + + expectTypeOf(result.error).toEqualTypeOf() + + expectTypeOf(result.error).not.toBeAny() + } + } + defaultDispatch(fetchLiveCallsError('asd')) + .then((result) => { + expectTypeOf(result.payload).toEqualTypeOf< + Item[] | ErrorFromServer | undefined + >() + + return result + }) + .then(unwrapResult) + .then((unwrapped) => { + expectTypeOf(unwrapped).toEqualTypeOf() + + expectTypeOf(unwrapResult).parameter(0).not.toMatchTypeOf(unwrapped) + }) + }) + }) + + describe('payloadCreator first argument type has impact on asyncThunk argument', () => { + test('asyncThunk has no argument', () => { + const asyncThunk = createAsyncThunk('test', () => 0) + + expectTypeOf(asyncThunk).toMatchTypeOf<() => any>() + + expectTypeOf(asyncThunk).parameters.toEqualTypeOf<[]>() + + expectTypeOf(asyncThunk).returns.toBeFunction() + }) + + test('one argument, specified as undefined: asyncThunk has no argument', () => { + const asyncThunk = createAsyncThunk('test', (arg: undefined) => 0) + + expectTypeOf(asyncThunk).toMatchTypeOf<() => any>() + + expectTypeOf(asyncThunk).parameters.toEqualTypeOf<[]>() + }) + + test('one argument, specified as void: asyncThunk has no argument', () => { + const asyncThunk = createAsyncThunk('test', (arg: void) => 0) + + expectTypeOf(asyncThunk).toMatchTypeOf<() => any>() + }) + + test('one argument, specified as optional number: asyncThunk has optional number argument', () => { + // this test will fail with strictNullChecks: false, that is to be expected + // in that case, we have to forbid this behaviour or it will make arguments optional everywhere + const asyncThunk = createAsyncThunk('test', (arg?: number) => 0) + + // Per https://github.com/reduxjs/redux-toolkit/issues/3758#issuecomment-1742152774 , this is a bug in + // TS 5.1 and 5.2, that is fixed in 5.3. Conditionally run the TS assertion here. + type IsTS51Or52 = TSVersion.Major extends 5 + ? TSVersion.Minor extends 1 | 2 + ? true + : false + : false + + type expectedType = IsTS51Or52 extends true + ? (arg: number) => any + : (arg?: number) => any + + expectTypeOf(asyncThunk).toMatchTypeOf() + + // We _should_ be able to call this with no arguments, but we run into that error in 5.1 and 5.2. + // Disabling this for now. + // asyncThunk() + expectTypeOf(asyncThunk).toBeCallableWith(5) + + expectTypeOf(asyncThunk).parameters.not.toMatchTypeOf<[string]>() + }) + + test('one argument, specified as number|undefined: asyncThunk has optional number argument', () => { + // this test will fail with strictNullChecks: false, that is to be expected + // in that case, we have to forbid this behaviour or it will make arguments optional everywhere + const asyncThunk = createAsyncThunk( + 'test', + (arg: number | undefined) => 0, + ) + + expectTypeOf(asyncThunk).toMatchTypeOf<(arg?: number) => any>() + + expectTypeOf(asyncThunk).toBeCallableWith() + + expectTypeOf(asyncThunk).toBeCallableWith(undefined) + + expectTypeOf(asyncThunk).toBeCallableWith(5) + + expectTypeOf(asyncThunk).parameters.not.toMatchTypeOf<[string]>() + }) + + test('one argument, specified as number|void: asyncThunk has optional number argument', () => { + const asyncThunk = createAsyncThunk('test', (arg: number | void) => 0) + + expectTypeOf(asyncThunk).toMatchTypeOf<(arg?: number) => any>() + + expectTypeOf(asyncThunk).toBeCallableWith() + + expectTypeOf(asyncThunk).toBeCallableWith(undefined) + + expectTypeOf(asyncThunk).toBeCallableWith(5) + + expectTypeOf(asyncThunk).parameters.not.toMatchTypeOf<[string]>() + }) + + test('one argument, specified as any: asyncThunk has required any argument', () => { + const asyncThunk = createAsyncThunk('test', (arg: any) => 0) + + expectTypeOf(asyncThunk).parameter(0).toBeAny() + + expectTypeOf(asyncThunk).toBeCallableWith(5) + + expectTypeOf(asyncThunk).parameters.not.toMatchTypeOf<[]>() + }) + + test('one argument, specified as unknown: asyncThunk has required unknown argument', () => { + const asyncThunk = createAsyncThunk('test', (arg: unknown) => 0) + + expectTypeOf(asyncThunk).parameter(0).toBeUnknown() + + expectTypeOf(asyncThunk).toBeCallableWith(5) + + expectTypeOf(asyncThunk).parameters.not.toMatchTypeOf<[]>() + }) + + test('one argument, specified as number: asyncThunk has required number argument', () => { + const asyncThunk = createAsyncThunk('test', (arg: number) => 0) + + expectTypeOf(asyncThunk).toMatchTypeOf<(arg: number) => any>() + + expectTypeOf(asyncThunk).toBeCallableWith(5) + + expectTypeOf(asyncThunk).parameters.not.toMatchTypeOf<[]>() + }) + + test('two arguments, first specified as undefined: asyncThunk has no argument', () => { + const asyncThunk = createAsyncThunk( + 'test', + (arg: undefined, thunkApi) => 0, + ) + + expectTypeOf(asyncThunk).toMatchTypeOf<() => any>() + + expectTypeOf(asyncThunk).toBeCallableWith() + + // @ts-expect-error cannot be called with an argument, even if the argument is `undefined` + expectTypeOf(asyncThunk).toBeCallableWith(undefined) + + // cannot be called with an argument + expectTypeOf(asyncThunk).parameter(0).not.toBeAny() + + expectTypeOf(asyncThunk).parameters.toEqualTypeOf<[]>() + }) + + test('two arguments, first specified as void: asyncThunk has no argument', () => { + const asyncThunk = createAsyncThunk('test', (arg: void, thunkApi) => 0) + + expectTypeOf(asyncThunk).toMatchTypeOf<() => any>() + + expectTypeOf(asyncThunk).toBeCallableWith() + + expectTypeOf(asyncThunk).parameter(0).toBeVoid() + + // cannot be called with an argument + expectTypeOf(asyncThunk).parameter(0).not.toBeAny() + + expectTypeOf(asyncThunk).parameters.toEqualTypeOf<[]>() + }) + + test('two arguments, first specified as number|undefined: asyncThunk has optional number argument', () => { + // this test will fail with strictNullChecks: false, that is to be expected + // in that case, we have to forbid this behaviour or it will make arguments optional everywhere + const asyncThunk = createAsyncThunk( + 'test', + (arg: number | undefined, thunkApi) => 0, + ) + + expectTypeOf(asyncThunk).toMatchTypeOf<(arg?: number) => any>() + + expectTypeOf(asyncThunk).toBeCallableWith() + + expectTypeOf(asyncThunk).toBeCallableWith(undefined) + + expectTypeOf(asyncThunk).toBeCallableWith(5) + + expectTypeOf(asyncThunk).parameter(0).not.toBeString() + }) + + test('two arguments, first specified as number|void: asyncThunk has optional number argument', () => { + const asyncThunk = createAsyncThunk( + 'test', + (arg: number | void, thunkApi) => 0, + ) + + expectTypeOf(asyncThunk).toMatchTypeOf<(arg?: number) => any>() + + expectTypeOf(asyncThunk).toBeCallableWith() + + expectTypeOf(asyncThunk).toBeCallableWith(undefined) + + expectTypeOf(asyncThunk).toBeCallableWith(5) + + expectTypeOf(asyncThunk).parameter(0).not.toBeString() + }) + + test('two arguments, first specified as any: asyncThunk has required any argument', () => { + const asyncThunk = createAsyncThunk('test', (arg: any, thunkApi) => 0) + + expectTypeOf(asyncThunk).parameter(0).toBeAny() + + expectTypeOf(asyncThunk).toBeCallableWith(5) + + expectTypeOf(asyncThunk).parameters.not.toMatchTypeOf<[]>() + }) + + test('two arguments, first specified as unknown: asyncThunk has required unknown argument', () => { + const asyncThunk = createAsyncThunk('test', (arg: unknown, thunkApi) => 0) + + expectTypeOf(asyncThunk).parameter(0).toBeUnknown() + + expectTypeOf(asyncThunk).toBeCallableWith(5) + + expectTypeOf(asyncThunk).parameters.not.toMatchTypeOf<[]>() + }) + + test('two arguments, first specified as number: asyncThunk has required number argument', () => { + const asyncThunk = createAsyncThunk('test', (arg: number, thunkApi) => 0) + + expectTypeOf(asyncThunk).toMatchTypeOf<(arg: number) => any>() + + expectTypeOf(asyncThunk).parameter(0).toBeNumber() + + expectTypeOf(asyncThunk).toBeCallableWith(5) + + expectTypeOf(asyncThunk).parameters.not.toMatchTypeOf<[]>() + }) + }) + + test('createAsyncThunk without generics', () => { + const thunk = createAsyncThunk('test', () => { + return 'ret' as const + }) + + expectTypeOf(thunk).toEqualTypeOf>() + }) + + test('createAsyncThunk without generics, accessing `api` does not break return type', () => { + const thunk = createAsyncThunk('test', (_: void, api) => { + return 'ret' as const + }) + + expectTypeOf(thunk).toEqualTypeOf>() + }) + + test('createAsyncThunk rejectWithValue without generics: Expect correct return type', () => { + const asyncThunk = createAsyncThunk( + 'test', + (_: void, { rejectWithValue }) => { + try { + return Promise.resolve(true) + } catch (e) { + return rejectWithValue(e) + } + }, + ) + + defaultDispatch(asyncThunk()) + .then((result) => { + if (asyncThunk.fulfilled.match(result)) { + expectTypeOf(result).toEqualTypeOf< + ReturnType<(typeof asyncThunk)['fulfilled']> + >() + + expectTypeOf(result.payload).toBeBoolean() + + expectTypeOf(result).not.toHaveProperty('error') + } else { + expectTypeOf(result).toEqualTypeOf< + ReturnType<(typeof asyncThunk)['rejected']> + >() + + expectTypeOf(result.error).toEqualTypeOf() + + expectTypeOf(result.payload).toBeUnknown() + } + + return result + }) + .then(unwrapResult) + .then((unwrapped) => { + expectTypeOf(unwrapped).toBeBoolean() + }) + }) + + test('createAsyncThunk with generics', () => { + type Funky = { somethingElse: 'Funky!' } + function funkySerializeError(err: any): Funky { + return { somethingElse: 'Funky!' } + } + + // has to stay on one line or type tests fail in older TS versions + // prettier-ignore + // @ts-expect-error + const shouldFail = createAsyncThunk('without generics', () => {}, { serializeError: funkySerializeError }) + + const shouldWork = createAsyncThunk< + any, + void, + { serializedErrorType: Funky } + >('with generics', () => {}, { + serializeError: funkySerializeError, + }) + + if (shouldWork.rejected.match(unknownAction)) { + expectTypeOf(unknownAction.error).toEqualTypeOf() + } + }) + + test('`idGenerator` option takes no arguments, and returns a string', () => { + const returnsNumWithArgs = (foo: any) => 100 + // has to stay on one line or type tests fail in older TS versions + // prettier-ignore + // @ts-expect-error + const shouldFailNumWithArgs = createAsyncThunk('foo', () => {}, { idGenerator: returnsNumWithArgs }) + + const returnsNumWithoutArgs = () => 100 + // prettier-ignore + // @ts-expect-error + const shouldFailNumWithoutArgs = createAsyncThunk('foo', () => {}, { idGenerator: returnsNumWithoutArgs }) + + const returnsStrWithNumberArg = (foo: number) => 'foo' + // prettier-ignore + // @ts-expect-error + const shouldFailWrongArgs = createAsyncThunk('foo', (arg: string) => {}, { idGenerator: returnsStrWithNumberArg }) + + const returnsStrWithStringArg = (foo: string) => 'foo' + const shoulducceedCorrectArgs = createAsyncThunk( + 'foo', + (arg: string) => {}, + { + idGenerator: returnsStrWithStringArg, + }, + ) + + const returnsStrWithoutArgs = () => 'foo' + const shouldSucceed = createAsyncThunk('foo', () => {}, { + idGenerator: returnsStrWithoutArgs, + }) + }) + + test('fulfillWithValue should infer return value', () => { + // https://github.com/reduxjs/redux-toolkit/issues/2886 + + const initialState = { + loading: false, + obj: { magic: '' }, + } + + const getObj = createAsyncThunk( + 'slice/getObj', + async (_: any, { fulfillWithValue, rejectWithValue }) => { + try { + return fulfillWithValue({ magic: 'object' }) + } catch (rejected: any) { + return rejectWithValue(rejected?.response?.error || rejected) + } + }, + ) + + createSlice({ + name: 'slice', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(getObj.fulfilled, (state, action) => { + expectTypeOf(action.payload).toEqualTypeOf<{ magic: string }>() + }) + }, + }) + }) + + test('meta return values', () => { + // return values + createAsyncThunk<'ret', void, {}>('test', (_, api) => 'ret' as const) + createAsyncThunk<'ret', void, {}>('test', async (_, api) => 'ret' as const) + createAsyncThunk<'ret', void, { fulfilledMeta: string }>('test', (_, api) => + api.fulfillWithValue('ret' as const, ''), + ) + createAsyncThunk<'ret', void, { fulfilledMeta: string }>( + 'test', + async (_, api) => api.fulfillWithValue('ret' as const, ''), + ) + createAsyncThunk<'ret', void, { fulfilledMeta: string }>( + 'test', + // @ts-expect-error has to be a fulfilledWithValue call + (_, api) => 'ret' as const, + ) + createAsyncThunk<'ret', void, { fulfilledMeta: string }>( + 'test', + // @ts-expect-error has to be a fulfilledWithValue call + async (_, api) => 'ret' as const, + ) + createAsyncThunk<'ret', void, { fulfilledMeta: string }>( + 'test', // @ts-expect-error should only allow returning with 'test' + (_, api) => api.fulfillWithValue(5, ''), + ) + createAsyncThunk<'ret', void, { fulfilledMeta: string }>( + 'test', // @ts-expect-error should only allow returning with 'test' + async (_, api) => api.fulfillWithValue(5, ''), + ) + + // reject values + createAsyncThunk<'ret', void, { rejectValue: string }>('test', (_, api) => + api.rejectWithValue('ret'), + ) + createAsyncThunk<'ret', void, { rejectValue: string }>( + 'test', + async (_, api) => api.rejectWithValue('ret'), + ) + createAsyncThunk< + 'ret', + void, + { rejectValue: string; rejectedMeta: number } + >('test', (_, api) => api.rejectWithValue('ret', 5)) + createAsyncThunk< + 'ret', + void, + { rejectValue: string; rejectedMeta: number } + >('test', async (_, api) => api.rejectWithValue('ret', 5)) + createAsyncThunk< + 'ret', + void, + { rejectValue: string; rejectedMeta: number } + >('test', (_, api) => api.rejectWithValue('ret', 5)) + createAsyncThunk< + 'ret', + void, + { rejectValue: string; rejectedMeta: number } + >( + 'test', + // @ts-expect-error wrong rejectedMeta type + (_, api) => api.rejectWithValue('ret', ''), + ) + createAsyncThunk< + 'ret', + void, + { rejectValue: string; rejectedMeta: number } + >( + 'test', + // @ts-expect-error wrong rejectedMeta type + async (_, api) => api.rejectWithValue('ret', ''), + ) + createAsyncThunk< + 'ret', + void, + { rejectValue: string; rejectedMeta: number } + >( + 'test', + // @ts-expect-error wrong rejectValue type + (_, api) => api.rejectWithValue(5, ''), + ) + createAsyncThunk< + 'ret', + void, + { rejectValue: string; rejectedMeta: number } + >( + 'test', + // @ts-expect-error wrong rejectValue type + async (_, api) => api.rejectWithValue(5, ''), + ) + }) + + test('usage with config override generic', () => { + const typedCAT = createAsyncThunk.withTypes<{ + state: RootState + dispatch: AppDispatch + rejectValue: string + extra: { s: string; n: number } + }>() + + // inferred usage + const thunk = typedCAT('foo', (arg: number, api) => { + // correct getState Type + const test1: number = api.getState().foo.value + // correct dispatch type + const test2: number = api.dispatch((dispatch, getState) => { + expectTypeOf(dispatch).toEqualTypeOf< + ThunkDispatch<{ foo: { value: number } }, undefined, UnknownAction> + >() + + expectTypeOf(getState).toEqualTypeOf<() => { foo: { value: number } }>() + + return getState().foo.value + }) + + // correct extra type + const { s, n } = api.extra + + expectTypeOf(s).toBeString() + + expectTypeOf(n).toBeNumber() + + if (1 < 2) + // @ts-expect-error + return api.rejectWithValue(5) + if (1 < 2) return api.rejectWithValue('test') + return test1 + test2 + }) + + // usage with two generics + const thunk2 = typedCAT('foo', (arg, api) => { + expectTypeOf(arg).toBeString() + + // correct getState Type + const test1: number = api.getState().foo.value + // correct dispatch type + const test2: number = api.dispatch((dispatch, getState) => { + expectTypeOf(dispatch).toEqualTypeOf< + ThunkDispatch<{ foo: { value: number } }, undefined, UnknownAction> + >() + + expectTypeOf(getState).toEqualTypeOf<() => { foo: { value: number } }>() + + return getState().foo.value + }) + // correct extra type + const { s, n } = api.extra + + expectTypeOf(s).toBeString() + + expectTypeOf(n).toBeNumber() + + if (1 < 2) expectTypeOf(api.rejectWithValue).toBeCallableWith('test') + + expectTypeOf(api.rejectWithValue).parameter(0).not.toBeNumber() + + expectTypeOf(api.rejectWithValue).parameters.toEqualTypeOf<[string]>() + + return api.rejectWithValue('test') + }) + + // usage with config override generic + const thunk3 = typedCAT( + 'foo', + (arg, api) => { + expectTypeOf(arg).toBeString() + + // correct getState Type + const test1: number = api.getState().foo.value + // correct dispatch type + const test2: number = api.dispatch((dispatch, getState) => { + expectTypeOf(dispatch).toEqualTypeOf< + ThunkDispatch<{ foo: { value: number } }, undefined, UnknownAction> + >() + + expectTypeOf(getState).toEqualTypeOf< + () => { foo: { value: number } } + >() + + return getState().foo.value + }) + // correct extra type + const { s, n } = api.extra + + expectTypeOf(s).toBeString() + + expectTypeOf(n).toBeNumber() + + if (1 < 2) return api.rejectWithValue(5) + if (1 < 2) expectTypeOf(api.rejectWithValue).toBeCallableWith(5) + + expectTypeOf(api.rejectWithValue).parameter(0).not.toBeString() + + expectTypeOf(api.rejectWithValue).parameters.toEqualTypeOf<[number]>() + + return api.rejectWithValue(5) + }, + ) + + const slice = createSlice({ + name: 'foo', + initialState: { value: 0 }, + reducers: {}, + extraReducers(builder) { + builder + .addCase(thunk.fulfilled, (state, action) => { + state.value += action.payload + }) + .addCase(thunk.rejected, (state, action) => { + expectTypeOf(action.payload).toEqualTypeOf() + }) + .addCase(thunk2.fulfilled, (state, action) => { + state.value += action.payload + }) + .addCase(thunk2.rejected, (state, action) => { + expectTypeOf(action.payload).toEqualTypeOf() + }) + .addCase(thunk3.fulfilled, (state, action) => { + state.value += action.payload + }) + .addCase(thunk3.rejected, (state, action) => { + expectTypeOf(action.payload).toEqualTypeOf() + }) + }, + }) + + const store = configureStore({ + reducer: { + foo: slice.reducer, + }, + }) + + type RootState = ReturnType + type AppDispatch = typeof store.dispatch + }) + + test('rejectedMeta', async () => { + const getNewStore = () => + configureStore({ + reducer(actions = [], action) { + return [...actions, action] + }, + }) + + const store = getNewStore() + + const fulfilledThunk = createAsyncThunk< + string, + string, + { rejectedMeta: { extraProp: string } } + >('test', (arg: string, { rejectWithValue }) => { + return rejectWithValue('damn!', { extraProp: 'baz' }) + }) + + const promise = store.dispatch(fulfilledThunk('testArg')) + + const ret = await promise + + if (ret.meta.requestStatus === 'rejected' && ret.meta.rejectedWithValue) { + expectTypeOf(ret.meta.extraProp).toBeString() + } else { + // could be caused by a `throw`, `abort()` or `condition` - no `rejectedMeta` in that case + expectTypeOf(ret.meta).not.toHaveProperty('extraProp') + } + }) +}) diff --git a/packages/toolkit/src/tests/createAsyncThunk.test.ts b/packages/toolkit/src/tests/createAsyncThunk.test.ts index 7134409e7b..cf39d269a9 100644 --- a/packages/toolkit/src/tests/createAsyncThunk.test.ts +++ b/packages/toolkit/src/tests/createAsyncThunk.test.ts @@ -1,10 +1,10 @@ -import { miniSerializeError } from '@internal/createAsyncThunk' import type { UnknownAction } from '@reduxjs/toolkit' import { configureStore, createAsyncThunk, createReducer, unwrapResult, + miniSerializeError } from '@reduxjs/toolkit' import { vi } from 'vitest' @@ -13,8 +13,7 @@ import { getLog, mockConsole, } from 'console-testing-library/pure' -import { delay } from '../utils' -import { expectType } from './utils/typeTestHelpers' +import { delay } from '@internal/utils' declare global { interface Window { @@ -683,7 +682,7 @@ describe('conditional skipping of asyncThunks', () => { }, meta: { aborted: false, - arg: arg, + arg, rejectedWithValue: false, condition: true, requestId: expect.stringContaining(''), @@ -896,7 +895,7 @@ describe('meta', () => { return [...actions, action] }, }) - let store = getNewStore() + const store = getNewStore() beforeEach(() => { const store = getNewStore() @@ -970,7 +969,6 @@ describe('meta', () => { }) if (ret.meta.requestStatus === 'rejected' && ret.meta.rejectedWithValue) { - expectType(ret.meta.extraProp) } else { // could be caused by a `throw`, `abort()` or `condition` - no `rejectedMeta` in that case // @ts-expect-error diff --git a/packages/toolkit/src/tests/createAsyncThunk.typetest.ts b/packages/toolkit/src/tests/createAsyncThunk.typetest.ts deleted file mode 100644 index 76d1cbb8ca..0000000000 --- a/packages/toolkit/src/tests/createAsyncThunk.typetest.ts +++ /dev/null @@ -1,772 +0,0 @@ -/* eslint-disable no-lone-blocks */ -import type { - AsyncThunk, - SerializedError, - UnknownAction, -} from '@reduxjs/toolkit' -import { - configureStore, - createAsyncThunk, - createReducer, - createSlice, - unwrapResult, -} from '@reduxjs/toolkit' -import type { ThunkDispatch } from 'redux-thunk' - -import type { - AsyncThunkFulfilledActionCreator, - AsyncThunkRejectedActionCreator, -} from '@internal/createAsyncThunk' -import type { IsAny, IsUnknown } from '@internal/tsHelpers' -import type { TSVersion } from '@phryneas/ts-version' -import type { AxiosError } from 'axios' -import apiRequest from 'axios' -import { expectExactType, expectType } from './utils/typeTestHelpers' - -const ANY = {} as any -const defaultDispatch = (() => {}) as ThunkDispatch<{}, any, UnknownAction> -const unknownAction = { type: 'foo' } as UnknownAction - -// basic usage -;(async function () { - const async = createAsyncThunk('test', (id: number) => - Promise.resolve(id * 2) - ) - - const reducer = createReducer({}, (builder) => - builder - .addCase(async.pending, (_, action) => { - expectType>(action) - }) - .addCase(async.fulfilled, (_, action) => { - expectType>(action) - expectType(action.payload) - }) - .addCase(async.rejected, (_, action) => { - expectType>(action) - expectType | undefined>(action.error) - }) - ) - - const promise = defaultDispatch(async(3)) - - expectType(promise.requestId) - expectType(promise.arg) - expectType<(reason?: string) => void>(promise.abort) - - const result = await promise - - if (async.fulfilled.match(result)) { - expectType>(result) - // @ts-expect-error - expectType>(result) - } else { - expectType>(result) - // @ts-expect-error - expectType>(result) - } - - promise - .then(unwrapResult) - .then((result) => { - expectType(result) - // @ts-expect-error - expectType(result) - }) - .catch((error) => { - // catch is always any-typed, nothing we can do here - }) -})() - -// More complex usage of thunk args -;(async function () { - interface BookModel { - id: string - title: string - } - - type BooksState = BookModel[] - - const fakeBooks: BookModel[] = [ - { id: 'b', title: 'Second' }, - { id: 'a', title: 'First' }, - ] - - const correctDispatch = (() => {}) as ThunkDispatch< - BookModel[], - { userAPI: Function }, - UnknownAction - > - - // Verify that the the first type args to createAsyncThunk line up right - const fetchBooksTAC = createAsyncThunk< - BookModel[], - number, - { - state: BooksState - extra: { userAPI: Function } - } - >( - 'books/fetch', - async (arg, { getState, dispatch, extra, requestId, signal }) => { - const state = getState() - - expectType(arg) - expectType(state) - expectType<{ userAPI: Function }>(extra) - return fakeBooks - } - ) - - correctDispatch(fetchBooksTAC(1)) - // @ts-expect-error - defaultDispatch(fetchBooksTAC(1)) -})() -/** - * returning a rejected action from the promise creator is possible - */ -;(async () => { - type ReturnValue = { data: 'success' } - type RejectValue = { data: 'error' } - - const fetchBooksTAC = createAsyncThunk< - ReturnValue, - number, - { - rejectValue: RejectValue - } - >('books/fetch', async (arg, { rejectWithValue }) => { - return rejectWithValue({ data: 'error' }) - }) - - const returned = await defaultDispatch(fetchBooksTAC(1)) - if (fetchBooksTAC.rejected.match(returned)) { - expectType(returned.payload) - expectType(returned.payload!) - } else { - expectType(returned.payload) - } - - expectType(unwrapResult(returned)) - // @ts-expect-error - expectType(unwrapResult(returned)) -})() - -/** - * regression #1156: union return values fall back to allowing only single member - */ -;(async () => { - const fn = createAsyncThunk('session/isAdmin', async () => { - const response: boolean = false - return response - }) -})() - -/** - * Should handle reject withvalue within a try catch block - * - * Note: - * this is a sample code taken from #1605 - * - */ -;(async () => { - type ResultType = { - text: string - } - const demoPromise = async (): Promise => - new Promise((resolve, _) => resolve({ text: '' })) - const thunk = createAsyncThunk('thunk', async (args, thunkAPI) => { - try { - const result = await demoPromise() - return result - } catch (error) { - return thunkAPI.rejectWithValue(error) - } - }) - createReducer({}, (builder) => - builder.addCase(thunk.fulfilled, (s, action) => { - expectType(action.payload) - }) - ) -})() - -{ - interface Item { - name: string - } - - interface ErrorFromServer { - error: string - } - - interface CallsResponse { - data: Item[] - } - - const fetchLiveCallsError = createAsyncThunk< - Item[], - string, - { - rejectValue: ErrorFromServer - } - >('calls/fetchLiveCalls', async (organizationId, { rejectWithValue }) => { - try { - const result = await apiRequest.get( - `organizations/${organizationId}/calls/live/iwill404` - ) - return result.data.data - } catch (err) { - let error: AxiosError = err as any // cast for access to AxiosError properties - if (!error.response) { - // let it be handled as any other unknown error - throw err - } - return rejectWithValue(error.response && error.response.data) - } - }) - - defaultDispatch(fetchLiveCallsError('asd')).then((result) => { - if (fetchLiveCallsError.fulfilled.match(result)) { - //success - expectType>(result) - expectType(result.payload) - } else { - expectType>(result) - if (result.payload) { - // rejected with value - expectType(result.payload) - } else { - // rejected by throw - expectType(result.payload) - expectType(result.error) - // @ts-expect-error - expectType>(true) - } - } - defaultDispatch(fetchLiveCallsError('asd')) - .then((result) => { - expectType(result.payload) - // @ts-expect-error - expectType(unwrapped) - return result - }) - .then(unwrapResult) - .then((unwrapped) => { - expectType(unwrapped) - // @ts-expect-error - expectType(unwrapResult(unwrapped)) - }) - }) -} - -/** - * payloadCreator first argument type has impact on asyncThunk argument - */ -{ - // no argument: asyncThunk has no argument - { - const asyncThunk = createAsyncThunk('test', () => 0) - expectType<() => any>(asyncThunk) - // @ts-expect-error cannot be called with an argument - asyncThunk(0 as any) - } - - // one argument, specified as undefined: asyncThunk has no argument - { - const asyncThunk = createAsyncThunk('test', (arg: undefined) => 0) - expectType<() => any>(asyncThunk) - // @ts-expect-error cannot be called with an argument - asyncThunk(0 as any) - } - - // one argument, specified as void: asyncThunk has no argument - { - const asyncThunk = createAsyncThunk('test', (arg: void) => 0) - expectType<() => any>(asyncThunk) - // @ts-expect-error cannot be called with an argument - asyncThunk(0 as any) - } - - // one argument, specified as optional number: asyncThunk has optional number argument - // this test will fail with strictNullChecks: false, that is to be expected - // in that case, we have to forbid this behaviour or it will make arguments optional everywhere - { - const asyncThunk = createAsyncThunk('test', (arg?: number) => 0) - - // Per https://github.com/reduxjs/redux-toolkit/issues/3758#issuecomment-1742152774 , this is a bug in - // TS 5.1 and 5.2, that is fixed in 5.3. Conditionally run the TS assertion here. - type IsTS51Or52 = TSVersion.Major extends 5 - ? TSVersion.Minor extends 1 | 2 - ? true - : false - : false - - type expectedType = IsTS51Or52 extends true - ? (arg: number) => any - : (arg?: number) => any - expectType(asyncThunk) - // We _should_ be able to call this with no arguments, but we run into that error in 5.1 and 5.2. - // Disabling this for now. - // asyncThunk() - asyncThunk(5) - // @ts-expect-error - asyncThunk('string') - } - - // one argument, specified as number|undefined: asyncThunk has optional number argument - // this test will fail with strictNullChecks: false, that is to be expected - // in that case, we have to forbid this behaviour or it will make arguments optional everywhere - { - const asyncThunk = createAsyncThunk('test', (arg: number | undefined) => 0) - expectType<(arg?: number) => any>(asyncThunk) - asyncThunk() - asyncThunk(5) - // @ts-expect-error - asyncThunk('string') - } - - // one argument, specified as number|void: asyncThunk has optional number argument - { - const asyncThunk = createAsyncThunk('test', (arg: number | void) => 0) - expectType<(arg?: number) => any>(asyncThunk) - asyncThunk() - asyncThunk(5) - // @ts-expect-error - asyncThunk('string') - } - - // one argument, specified as any: asyncThunk has required any argument - { - const asyncThunk = createAsyncThunk('test', (arg: any) => 0) - expectType[0], true, false>>(true) - asyncThunk(5) - // @ts-expect-error - asyncThunk() - } - - // one argument, specified as unknown: asyncThunk has required unknown argument - { - const asyncThunk = createAsyncThunk('test', (arg: unknown) => 0) - expectType[0], true, false>>(true) - asyncThunk(5) - // @ts-expect-error - asyncThunk() - } - - // one argument, specified as number: asyncThunk has required number argument - { - const asyncThunk = createAsyncThunk('test', (arg: number) => 0) - expectType<(arg: number) => any>(asyncThunk) - asyncThunk(5) - // @ts-expect-error - asyncThunk() - } - - // two arguments, first specified as undefined: asyncThunk has no argument - { - const asyncThunk = createAsyncThunk('test', (arg: undefined, thunkApi) => 0) - expectType<() => any>(asyncThunk) - // @ts-expect-error cannot be called with an argument - asyncThunk(0 as any) - } - - // two arguments, first specified as void: asyncThunk has no argument - { - const asyncThunk = createAsyncThunk('test', (arg: void, thunkApi) => 0) - expectType<() => any>(asyncThunk) - // @ts-expect-error cannot be called with an argument - asyncThunk(0 as any) - } - - // two arguments, first specified as number|undefined: asyncThunk has optional number argument - // this test will fail with strictNullChecks: false, that is to be expected - // in that case, we have to forbid this behaviour or it will make arguments optional everywhere - { - const asyncThunk = createAsyncThunk( - 'test', - (arg: number | undefined, thunkApi) => 0 - ) - expectType<(arg?: number) => any>(asyncThunk) - asyncThunk() - asyncThunk(5) - // @ts-expect-error - asyncThunk('string') - } - - // two arguments, first specified as number|void: asyncThunk has optional number argument - { - const asyncThunk = createAsyncThunk( - 'test', - (arg: number | void, thunkApi) => 0 - ) - expectType<(arg?: number) => any>(asyncThunk) - asyncThunk() - asyncThunk(5) - // @ts-expect-error - asyncThunk('string') - } - - // two arguments, first specified as any: asyncThunk has required any argument - { - const asyncThunk = createAsyncThunk('test', (arg: any, thunkApi) => 0) - expectType[0], true, false>>(true) - asyncThunk(5) - // @ts-expect-error - asyncThunk() - } - - // two arguments, first specified as unknown: asyncThunk has required unknown argument - { - const asyncThunk = createAsyncThunk('test', (arg: unknown, thunkApi) => 0) - expectType[0], true, false>>(true) - asyncThunk(5) - // @ts-expect-error - asyncThunk() - } - - // two arguments, first specified as number: asyncThunk has required number argument - { - const asyncThunk = createAsyncThunk('test', (arg: number, thunkApi) => 0) - expectType<(arg: number) => any>(asyncThunk) - asyncThunk(5) - // @ts-expect-error - asyncThunk() - } -} - -{ - // createAsyncThunk without generics - const thunk = createAsyncThunk('test', () => { - return 'ret' as const - }) - expectType>(thunk) -} - -{ - // createAsyncThunk without generics, accessing `api` does not break return type - const thunk = createAsyncThunk('test', (_: void, api) => { - console.log(api) - return 'ret' as const - }) - expectType>(thunk) -} - -// createAsyncThunk rejectWithValue without generics: Expect correct return type -{ - const asyncThunk = createAsyncThunk( - 'test', - (_: void, { rejectWithValue }) => { - try { - return Promise.resolve(true) - } catch (e) { - return rejectWithValue(e) - } - } - ) - - defaultDispatch(asyncThunk()) - .then((result) => { - if (asyncThunk.fulfilled.match(result)) { - expectType>>( - result - ) - expectType(result.payload) - // @ts-expect-error - expectType(result.error) - } else { - expectType>>( - result - ) - expectType(result.error) - expectType(result.payload) - } - - return result - }) - .then(unwrapResult) - .then((unwrapped) => { - expectType(unwrapped) - }) -} - -{ - type Funky = { somethingElse: 'Funky!' } - function funkySerializeError(err: any): Funky { - return { somethingElse: 'Funky!' } - } - - // has to stay on one line or type tests fail in older TS versions - // prettier-ignore - // @ts-expect-error - const shouldFail = createAsyncThunk('without generics', () => {}, { serializeError: funkySerializeError }) - - const shouldWork = createAsyncThunk< - any, - void, - { serializedErrorType: Funky } - >('with generics', () => {}, { - serializeError: funkySerializeError, - }) - - if (shouldWork.rejected.match(unknownAction)) { - expectType(unknownAction.error) - } -} - -/** - * `idGenerator` option takes no arguments, and returns a string - */ -{ - const returnsNumWithArgs = (foo: any) => 100 - // has to stay on one line or type tests fail in older TS versions - // prettier-ignore - // @ts-expect-error - const shouldFailNumWithArgs = createAsyncThunk('foo', () => {}, { idGenerator: returnsNumWithArgs }) - - const returnsNumWithoutArgs = () => 100 - // prettier-ignore - // @ts-expect-error - const shouldFailNumWithoutArgs = createAsyncThunk('foo', () => {}, { idGenerator: returnsNumWithoutArgs }) - - const returnsStrWithNumberArg = (foo: number) => 'foo' - // prettier-ignore - // @ts-expect-error - const shouldFailWrongArgs = createAsyncThunk('foo', (arg: string) => {}, { idGenerator: returnsStrWithNumberArg }) - - const returnsStrWithStringArg = (foo: string) => 'foo' - const shoulducceedCorrectArgs = createAsyncThunk('foo', (arg: string) => {}, { - idGenerator: returnsStrWithStringArg, - }) - - const returnsStrWithoutArgs = () => 'foo' - const shouldSucceed = createAsyncThunk('foo', () => {}, { - idGenerator: returnsStrWithoutArgs, - }) -} - -{ - // https://github.com/reduxjs/redux-toolkit/issues/2886 - // fulfillWithValue should infer return value - - const initialState = { - loading: false, - obj: { magic: '' }, - } - - const getObj = createAsyncThunk( - 'slice/getObj', - async (_: any, { fulfillWithValue, rejectWithValue }) => { - try { - return fulfillWithValue({ magic: 'object' }) - } catch (rejected: any) { - return rejectWithValue(rejected?.response?.error || rejected) - } - } - ) - - createSlice({ - name: 'slice', - initialState, - reducers: {}, - extraReducers: (builder) => { - builder.addCase(getObj.fulfilled, (state, action) => { - expectExactType<{ magic: string }>(ANY)(action.payload) - }) - }, - }) -} - -// meta return values -{ - // return values - createAsyncThunk<'ret', void, {}>('test', (_, api) => 'ret' as const) - createAsyncThunk<'ret', void, {}>('test', async (_, api) => 'ret' as const) - createAsyncThunk<'ret', void, { fulfilledMeta: string }>('test', (_, api) => - api.fulfillWithValue('ret' as const, '') - ) - createAsyncThunk<'ret', void, { fulfilledMeta: string }>( - 'test', - async (_, api) => api.fulfillWithValue('ret' as const, '') - ) - createAsyncThunk<'ret', void, { fulfilledMeta: string }>( - 'test', - // @ts-expect-error has to be a fulfilledWithValue call - (_, api) => 'ret' as const - ) - createAsyncThunk<'ret', void, { fulfilledMeta: string }>( - 'test', - // @ts-expect-error has to be a fulfilledWithValue call - async (_, api) => 'ret' as const - ) - createAsyncThunk<'ret', void, { fulfilledMeta: string }>( - 'test', // @ts-expect-error should only allow returning with 'test' - (_, api) => api.fulfillWithValue(5, '') - ) - createAsyncThunk<'ret', void, { fulfilledMeta: string }>( - 'test', // @ts-expect-error should only allow returning with 'test' - async (_, api) => api.fulfillWithValue(5, '') - ) - - // reject values - createAsyncThunk<'ret', void, { rejectValue: string }>('test', (_, api) => - api.rejectWithValue('ret') - ) - createAsyncThunk<'ret', void, { rejectValue: string }>( - 'test', - async (_, api) => api.rejectWithValue('ret') - ) - createAsyncThunk<'ret', void, { rejectValue: string; rejectedMeta: number }>( - 'test', - (_, api) => api.rejectWithValue('ret', 5) - ) - createAsyncThunk<'ret', void, { rejectValue: string; rejectedMeta: number }>( - 'test', - async (_, api) => api.rejectWithValue('ret', 5) - ) - createAsyncThunk<'ret', void, { rejectValue: string; rejectedMeta: number }>( - 'test', - (_, api) => api.rejectWithValue('ret', 5) - ) - createAsyncThunk<'ret', void, { rejectValue: string; rejectedMeta: number }>( - 'test', - // @ts-expect-error wrong rejectedMeta type - (_, api) => api.rejectWithValue('ret', '') - ) - createAsyncThunk<'ret', void, { rejectValue: string; rejectedMeta: number }>( - 'test', - // @ts-expect-error wrong rejectedMeta type - async (_, api) => api.rejectWithValue('ret', '') - ) - createAsyncThunk<'ret', void, { rejectValue: string; rejectedMeta: number }>( - 'test', - // @ts-expect-error wrong rejectValue type - (_, api) => api.rejectWithValue(5, '') - ) - createAsyncThunk<'ret', void, { rejectValue: string; rejectedMeta: number }>( - 'test', - // @ts-expect-error wrong rejectValue type - async (_, api) => api.rejectWithValue(5, '') - ) -} - -{ - const typedCAT = createAsyncThunk.withTypes<{ - state: RootState - dispatch: AppDispatch - rejectValue: string - extra: { s: string; n: number } - }>() - - // inferred usage - const thunk = typedCAT('foo', (arg: number, api) => { - // correct getState Type - const test1: number = api.getState().foo.value - // correct dispatch type - const test2: number = api.dispatch((dispatch, getState) => { - expectExactType< - ThunkDispatch<{ foo: { value: number } }, undefined, UnknownAction> - >(ANY)(dispatch) - expectExactType<() => { foo: { value: number } }>(ANY)(getState) - return getState().foo.value - }) - - // correct extra type - const { s, n } = api.extra - expectExactType(ANY)(s) - expectExactType(ANY)(n) - - if (1 < 2) - // @ts-expect-error - return api.rejectWithValue(5) - if (1 < 2) return api.rejectWithValue('test') - return test1 + test2 - }) - - // usage with two generics - const thunk2 = typedCAT('foo', (arg, api) => { - expectExactType('' as string)(arg) - // correct getState Type - const test1: number = api.getState().foo.value - // correct dispatch type - const test2: number = api.dispatch((dispatch, getState) => { - expectExactType< - ThunkDispatch<{ foo: { value: number } }, undefined, UnknownAction> - >(ANY)(dispatch) - expectExactType<() => { foo: { value: number } }>(ANY)(getState) - return getState().foo.value - }) - // correct extra type - const { s, n } = api.extra - expectExactType(ANY)(s) - expectExactType(ANY)(n) - - if (1 < 2) - // @ts-expect-error - return api.rejectWithValue(5) - if (1 < 2) return api.rejectWithValue('test') - return test1 + test2 - }) - - // usage with config override generic - const thunk3 = typedCAT( - 'foo', - (arg, api) => { - expectExactType('' as string)(arg) - // correct getState Type - const test1: number = api.getState().foo.value - // correct dispatch type - const test2: number = api.dispatch((dispatch, getState) => { - expectExactType< - ThunkDispatch<{ foo: { value: number } }, undefined, UnknownAction> - >(ANY)(dispatch) - expectExactType<() => { foo: { value: number } }>(ANY)(getState) - return getState().foo.value - }) - // correct extra type - const { s, n } = api.extra - expectExactType(ANY)(s) - expectExactType(ANY)(n) - if (1 < 2) return api.rejectWithValue(5) - if (1 < 2) - // @ts-expect-error - return api.rejectWithValue('test') - return 5 - } - ) - - const slice = createSlice({ - name: 'foo', - initialState: { value: 0 }, - reducers: {}, - extraReducers(builder) { - builder - .addCase(thunk.fulfilled, (state, action) => { - state.value += action.payload - }) - .addCase(thunk.rejected, (state, action) => { - expectExactType('' as string | undefined)(action.payload) - }) - .addCase(thunk2.fulfilled, (state, action) => { - state.value += action.payload - }) - .addCase(thunk2.rejected, (state, action) => { - expectExactType('' as string | undefined)(action.payload) - }) - .addCase(thunk3.fulfilled, (state, action) => { - state.value += action.payload - }) - .addCase(thunk3.rejected, (state, action) => { - expectExactType(0 as number | undefined)(action.payload) - }) - }, - }) - - const store = configureStore({ - reducer: { - foo: slice.reducer, - }, - }) - - type RootState = ReturnType - type AppDispatch = typeof store.dispatch -} diff --git a/packages/toolkit/src/tests/createEntityAdapter.test-d.ts b/packages/toolkit/src/tests/createEntityAdapter.test-d.ts new file mode 100644 index 0000000000..0e8a4c54f4 --- /dev/null +++ b/packages/toolkit/src/tests/createEntityAdapter.test-d.ts @@ -0,0 +1,161 @@ +import type { + ActionCreatorWithPayload, + ActionCreatorWithoutPayload, + EntityAdapter, + EntityId, + EntityStateAdapter, + Update, +} from '@reduxjs/toolkit' +import { createEntityAdapter, createSlice } from '@reduxjs/toolkit' + +function extractReducers( + adapter: EntityAdapter, +): EntityStateAdapter { + const { selectId, sortComparer, getInitialState, getSelectors, ...rest } = + adapter + return rest +} + +describe('type tests', () => { + test('should be usable in a slice, with all the "reducer-like" functions', () => { + type Id = string & { readonly __tag: unique symbol } + type Entity = { + id: Id + } + const adapter = createEntityAdapter() + const slice = createSlice({ + name: 'test', + initialState: adapter.getInitialState(), + reducers: { + ...extractReducers(adapter), + }, + }) + + expectTypeOf(slice.actions.addOne).toMatchTypeOf< + ActionCreatorWithPayload + >() + + expectTypeOf(slice.actions.addMany).toMatchTypeOf< + ActionCreatorWithPayload | Record> + >() + + expectTypeOf(slice.actions.setAll).toMatchTypeOf< + ActionCreatorWithPayload | Record> + >() + + expectTypeOf(slice.actions.removeOne).toMatchTypeOf< + ActionCreatorWithPayload + >() + + expectTypeOf(slice.actions.addMany).not.toMatchTypeOf< + ActionCreatorWithPayload> + >() + + expectTypeOf(slice.actions.setAll).not.toMatchTypeOf< + ActionCreatorWithPayload> + >() + + expectTypeOf(slice.actions.removeOne).toMatchTypeOf< + ActionCreatorWithPayload + >() + + expectTypeOf(slice.actions.removeMany).toMatchTypeOf< + ActionCreatorWithPayload> + >() + + expectTypeOf(slice.actions.removeMany).not.toMatchTypeOf< + ActionCreatorWithPayload + >() + + expectTypeOf( + slice.actions.removeAll, + ).toMatchTypeOf() + + expectTypeOf(slice.actions.updateOne).toMatchTypeOf< + ActionCreatorWithPayload> + >() + + expectTypeOf(slice.actions.updateMany).not.toMatchTypeOf< + ActionCreatorWithPayload[]> + >() + + expectTypeOf(slice.actions.upsertOne).toMatchTypeOf< + ActionCreatorWithPayload + >() + + expectTypeOf(slice.actions.updateMany).toMatchTypeOf< + ActionCreatorWithPayload>> + >() + + expectTypeOf(slice.actions.upsertOne).toMatchTypeOf< + ActionCreatorWithPayload + >() + + expectTypeOf(slice.actions.upsertMany).toMatchTypeOf< + ActionCreatorWithPayload | Record> + >() + + expectTypeOf(slice.actions.upsertMany).not.toMatchTypeOf< + ActionCreatorWithPayload> + >() + }) + + test('should not be able to mix with a different EntityAdapter', () => { + type Entity = { + id: EntityId + value: string + } + type Entity2 = { + id: EntityId + value2: string + } + const adapter = createEntityAdapter() + const adapter2 = createEntityAdapter() + createSlice({ + name: 'test', + initialState: adapter.getInitialState(), + reducers: { + addOne: adapter.addOne, + // @ts-expect-error + addOne2: adapter2.addOne, + }, + }) + }) + + test('should be usable in a slice with extra properties', () => { + type Entity = { id: EntityId; value: string } + const adapter = createEntityAdapter() + createSlice({ + name: 'test', + initialState: adapter.getInitialState({ extraData: 'test' }), + reducers: { + addOne: adapter.addOne, + }, + }) + }) + + test('should not be usable in a slice with an unfitting state', () => { + type Entity = { id: EntityId; value: string } + const adapter = createEntityAdapter() + createSlice({ + name: 'test', + initialState: { somethingElse: '' }, + reducers: { + // @ts-expect-error + addOne: adapter.addOne, + }, + }) + }) + + test('should not be able to create an adapter unless the type has an Id or an idSelector is provided', () => { + type Entity = { + value: string + } + // @ts-expect-error + const adapter = createEntityAdapter() + const adapter2: EntityAdapter = + createEntityAdapter({ + selectId: (e: Entity) => e.value, + }) + }) +}) diff --git a/packages/toolkit/src/tests/createEntityAdapter.typetest.ts b/packages/toolkit/src/tests/createEntityAdapter.typetest.ts deleted file mode 100644 index 0e2b9a45ec..0000000000 --- a/packages/toolkit/src/tests/createEntityAdapter.typetest.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { - ActionCreatorWithPayload, - ActionCreatorWithoutPayload, - EntityAdapter, - EntityId, - EntityStateAdapter, - Update, -} from '@reduxjs/toolkit' -import { createEntityAdapter, createSlice } from '@reduxjs/toolkit' -import { expectType } from './utils/typeTestHelpers' - -function extractReducers( - adapter: EntityAdapter -): Omit, 'map'> { - const { selectId, sortComparer, getInitialState, getSelectors, ...rest } = - adapter - return rest -} - -/** - * should be usable in a slice, with all the "reducer-like" functions - */ -{ - type Id = string & { readonly __tag: unique symbol } - type Entity = { - id: Id - } - const adapter = createEntityAdapter() - const slice = createSlice({ - name: 'test', - initialState: adapter.getInitialState(), - reducers: { - ...extractReducers(adapter), - }, - }) - - expectType>(slice.actions.addOne) - expectType< - ActionCreatorWithPayload | Record> - >(slice.actions.addMany) - expectType< - ActionCreatorWithPayload | Record> - >(slice.actions.setAll) - expectType>>( - // @ts-expect-error - slice.actions.addMany - ) - expectType>>( - // @ts-expect-error - slice.actions.setAll - ) - expectType>(slice.actions.removeOne) - expectType>>( - slice.actions.removeMany - ) - // @ts-expect-error - expectType>(slice.actions.removeMany) - expectType(slice.actions.removeAll) - expectType>>( - slice.actions.updateOne - ) - expectType[]>>( - // @ts-expect-error - slice.actions.updateMany - ) - expectType>>>( - slice.actions.updateMany - ) - expectType>(slice.actions.upsertOne) - expectType< - ActionCreatorWithPayload | Record> - >(slice.actions.upsertMany) - expectType>>( - // @ts-expect-error - slice.actions.upsertMany - ) -} - -/** - * should not be able to mix with a different EntityAdapter - */ -{ - type Entity = { - id: EntityId - value: string - } - type Entity2 = { - id: EntityId - value2: string - } - const adapter = createEntityAdapter() - const adapter2 = createEntityAdapter() - createSlice({ - name: 'test', - initialState: adapter.getInitialState(), - reducers: { - addOne: adapter.addOne, - // @ts-expect-error - addOne2: adapter2.addOne, - }, - }) -} - -/** - * should be usable in a slice with extra properties - */ -{ - type Entity = { id: EntityId; value: string } - const adapter = createEntityAdapter() - createSlice({ - name: 'test', - initialState: adapter.getInitialState({ extraData: 'test' }), - reducers: { - addOne: adapter.addOne, - }, - }) -} - -/** - * should not be usable in a slice with an unfitting state - */ -{ - type Entity = { id: EntityId; value: string } - const adapter = createEntityAdapter() - createSlice({ - name: 'test', - initialState: { somethingElse: '' }, - reducers: { - // @ts-expect-error - addOne: adapter.addOne, - }, - }) -} - -/** - * should not be able to create an adapter unless the type has an Id - * or an idSelector is provided - */ -{ - type Entity = { - value: string - } - // @ts-expect-error - const adapter = createEntityAdapter() - const adapter2: EntityAdapter = createEntityAdapter({ - selectId: (e: Entity) => e.value, - }) -} diff --git a/packages/toolkit/src/tests/createReducer.test-d.ts b/packages/toolkit/src/tests/createReducer.test-d.ts new file mode 100644 index 0000000000..535710a256 --- /dev/null +++ b/packages/toolkit/src/tests/createReducer.test-d.ts @@ -0,0 +1,74 @@ +import type { ActionReducerMapBuilder, Reducer } from '@reduxjs/toolkit' +import { createAction, createReducer } from '@reduxjs/toolkit' + +describe('type tests', () => { + test('createReducer() infers type of returned reducer.', () => { + const incrementHandler = ( + state: number, + action: { type: 'increment'; payload: number }, + ) => state + 1 + + const decrementHandler = ( + state: number, + action: { type: 'decrement'; payload: number }, + ) => state - 1 + + const reducer = createReducer(0 as number, (builder) => { + builder + .addCase('increment', incrementHandler) + .addCase('decrement', decrementHandler) + }) + + expectTypeOf(reducer).toMatchTypeOf>() + + expectTypeOf(reducer).not.toMatchTypeOf>() + }) + + test('createReducer() state type can be specified explicitly.', () => { + const incrementHandler = ( + state: number, + action: { type: 'increment'; payload: number }, + ) => state + action.payload + + const decrementHandler = ( + state: number, + action: { type: 'decrement'; payload: number }, + ) => state - action.payload + + createReducer(0 as number, (builder) => { + builder + .addCase('increment', incrementHandler) + .addCase('decrement', decrementHandler) + }) + + // @ts-expect-error + createReducer(0 as number, (builder) => { + // @ts-expect-error + builder + .addCase('increment', incrementHandler) + .addCase('decrement', decrementHandler) + }) + }) + + test('createReducer() ensures state type is mutable within a case reducer.', () => { + const initialState: { readonly counter: number } = { counter: 0 } + + createReducer(initialState, (builder) => { + builder.addCase('increment', (state) => { + state.counter += 1 + }) + }) + }) + + test('builder callback for actionMap', () => { + const increment = createAction('increment') + + const reducer = createReducer(0, (builder) => + expectTypeOf(builder).toEqualTypeOf>(), + ) + + expectTypeOf(reducer(0, increment(5))).toBeNumber() + + expectTypeOf(reducer(0, increment(5))).not.toBeString() + }) +}) diff --git a/packages/toolkit/src/tests/createReducer.typetest.ts b/packages/toolkit/src/tests/createReducer.typetest.ts deleted file mode 100644 index 90228dcedd..0000000000 --- a/packages/toolkit/src/tests/createReducer.typetest.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { ActionReducerMapBuilder } from '@reduxjs/toolkit' -import { createAction, createReducer } from '@reduxjs/toolkit' -import type { Reducer } from 'redux' -import { expectType } from './utils/typeTestHelpers' - -/* - * Test: createReducer() infers type of returned reducer. - */ -{ - const incrementHandler = ( - state: number, - action: { type: 'increment'; payload: number } - ) => state + 1 - const decrementHandler = ( - state: number, - action: { type: 'decrement'; payload: number } - ) => state - 1 - - const reducer = createReducer(0 as number, (builder) => { - builder - .addCase('increment', incrementHandler) - .addCase('decrement', decrementHandler) - }) - - const numberReducer: Reducer = reducer - - // @ts-expect-error - const stringReducer: Reducer = reducer -} - -/** - * Test: createReducer() state type can be specified expliclity. - */ -{ - const incrementHandler = ( - state: number, - action: { type: 'increment'; payload: number } - ) => state + action.payload - - const decrementHandler = ( - state: number, - action: { type: 'decrement'; payload: number } - ) => state - action.payload - - createReducer(0 as number, (builder) => { - builder - .addCase('increment', incrementHandler) - .addCase('decrement', decrementHandler) - }) - - // @ts-expect-error - createReducer(0 as number, (builder) => { - // @ts-expect-error - builder - .addCase('increment', incrementHandler) - .addCase('decrement', decrementHandler) - }) -} - -/* - * Test: createReducer() ensures state type is mutable within a case reducer. - */ -{ - const initialState: { readonly counter: number } = { counter: 0 } - - createReducer(initialState, (builder) => { - builder.addCase('increment', (state) => { - state.counter += 1 - }) - }) -} - -/** Test: builder callback for actionMap */ -{ - const increment = createAction('increment') - - const reducer = createReducer(0, (builder) => - expectType>(builder) - ) - - expectType(reducer(0, increment(5))) - // @ts-expect-error - expectType(reducer(0, increment(5))) -} diff --git a/packages/toolkit/src/tests/createSlice.test-d.ts b/packages/toolkit/src/tests/createSlice.test-d.ts new file mode 100644 index 0000000000..d76d8fa5ee --- /dev/null +++ b/packages/toolkit/src/tests/createSlice.test-d.ts @@ -0,0 +1,957 @@ +import type { + Action, + ActionCreatorWithNonInferrablePayload, + ActionCreatorWithOptionalPayload, + ActionCreatorWithPayload, + ActionCreatorWithPreparedPayload, + ActionCreatorWithoutPayload, + ActionReducerMapBuilder, + AsyncThunk, + CaseReducer, + PayloadAction, + PayloadActionCreator, + Reducer, + ReducerCreators, + SerializedError, + SliceCaseReducers, + ThunkDispatch, + UnknownAction, + ValidateSliceCaseReducers, +} from '@reduxjs/toolkit' +import { + asyncThunkCreator, + buildCreateSlice, + configureStore, + createAction, + createAsyncThunk, + createSlice, + isRejected, +} from '@reduxjs/toolkit' +import { castDraft } from 'immer' + +describe('type tests', () => { + const counterSlice = createSlice({ + name: 'counter', + initialState: 0, + reducers: { + increment: (state: number, action) => state + action.payload, + decrement: (state: number, action) => state - action.payload, + }, + }) + + test('Slice name is strongly typed.', () => { + const uiSlice = createSlice({ + name: 'ui', + initialState: 0, + reducers: { + goToNext: (state: number, action) => state + action.payload, + goToPrevious: (state: number, action) => state - action.payload, + }, + }) + + const actionCreators = { + [counterSlice.name]: { ...counterSlice.actions }, + [uiSlice.name]: { ...uiSlice.actions }, + } + + expectTypeOf(counterSlice.actions).toEqualTypeOf(actionCreators.counter) + + expectTypeOf(uiSlice.actions).toEqualTypeOf(actionCreators.ui) + + expectTypeOf(actionCreators).not.toHaveProperty('anyKey') + }) + + test("createSlice() infers the returned slice's type.", () => { + const firstAction = createAction<{ count: number }>('FIRST_ACTION') + + const slice = createSlice({ + name: 'counter', + initialState: 0, + reducers: { + increment: (state: number, action) => state + action.payload, + decrement: (state: number, action) => state - action.payload, + }, + extraReducers: (builder) => { + builder.addCase( + firstAction, + (state, action) => state + action.payload.count, + ) + }, + }) + + test('Reducer', () => { + expectTypeOf(slice.reducer).toMatchTypeOf< + Reducer + >() + + expectTypeOf(slice.reducer).not.toMatchTypeOf< + Reducer + >() + }) + + test('Actions', () => { + slice.actions.increment(1) + slice.actions.decrement(1) + + expectTypeOf(slice.actions).not.toHaveProperty('other') + }) + }) + + test('Slice action creator types are inferred.', () => { + const counter = createSlice({ + name: 'counter', + initialState: 0, + reducers: { + increment: (state) => state + 1, + decrement: ( + state, + { payload = 1 }: PayloadAction, + ) => state - payload, + multiply: (state, { payload }: PayloadAction) => + Array.isArray(payload) + ? payload.reduce((acc, val) => acc * val, state) + : state * payload, + addTwo: { + reducer: (s, { payload }: PayloadAction) => s + payload, + prepare: (a: number, b: number) => ({ + payload: a + b, + }), + }, + }, + }) + + expectTypeOf( + counter.actions.increment, + ).toMatchTypeOf() + + counter.actions.increment() + + expectTypeOf(counter.actions.decrement).toMatchTypeOf< + ActionCreatorWithOptionalPayload + >() + + counter.actions.decrement() + counter.actions.decrement(2) + + expectTypeOf(counter.actions.multiply).toMatchTypeOf< + ActionCreatorWithPayload + >() + + counter.actions.multiply(2) + counter.actions.multiply([2, 3, 4]) + + expectTypeOf(counter.actions.addTwo).toMatchTypeOf< + ActionCreatorWithPreparedPayload<[number, number], number> + >() + + counter.actions.addTwo(1, 2) + + expectTypeOf(counter.actions.multiply).parameters.not.toMatchTypeOf<[]>() + + expectTypeOf(counter.actions.multiply).parameter(0).not.toBeString() + + expectTypeOf(counter.actions.addTwo).parameters.not.toMatchTypeOf< + [number] + >() + + expectTypeOf(counter.actions.addTwo).parameters.toEqualTypeOf< + [number, number] + >() + }) + + test('Slice action creator types properties are strongly typed', () => { + const counter = createSlice({ + name: 'counter', + initialState: 0, + reducers: { + increment: (state) => state + 1, + decrement: (state) => state - 1, + multiply: (state, { payload }: PayloadAction) => + Array.isArray(payload) + ? payload.reduce((acc, val) => acc * val, state) + : state * payload, + }, + }) + + expectTypeOf( + counter.actions.increment.type, + ).toEqualTypeOf<'counter/increment'>() + + expectTypeOf( + counter.actions.increment().type, + ).toEqualTypeOf<'counter/increment'>() + + expectTypeOf( + counter.actions.decrement.type, + ).toEqualTypeOf<'counter/decrement'>() + + expectTypeOf( + counter.actions.decrement().type, + ).toEqualTypeOf<'counter/decrement'>() + + expectTypeOf( + counter.actions.multiply.type, + ).toEqualTypeOf<'counter/multiply'>() + + expectTypeOf( + counter.actions.multiply(1).type, + ).toEqualTypeOf<'counter/multiply'>() + + expectTypeOf( + counter.actions.increment.type, + ).not.toMatchTypeOf<'increment'>() + }) + + test('Slice action creator types are inferred for enhanced reducers.', () => { + const counter = createSlice({ + name: 'test', + initialState: { counter: 0, concat: '' }, + reducers: { + incrementByStrLen: { + reducer: (state, action: PayloadAction) => { + state.counter += action.payload + }, + prepare: (payload: string) => ({ + payload: payload.length, + }), + }, + concatMetaStrLen: { + reducer: (state, action: PayloadAction) => { + state.concat += action.payload + }, + prepare: (payload: string) => ({ + payload, + meta: payload.length, + }), + }, + }, + }) + + expectTypeOf( + counter.actions.incrementByStrLen('test').type, + ).toEqualTypeOf<'test/incrementByStrLen'>() + + expectTypeOf(counter.actions.incrementByStrLen('test').payload).toBeNumber() + + expectTypeOf(counter.actions.concatMetaStrLen('test').payload).toBeString() + + expectTypeOf(counter.actions.concatMetaStrLen('test').meta).toBeNumber() + + expectTypeOf( + counter.actions.incrementByStrLen('test').payload, + ).not.toBeString() + + expectTypeOf(counter.actions.concatMetaStrLen('test').meta).not.toBeString() + }) + + test('access meta and error from reducer', () => { + const counter = createSlice({ + name: 'test', + initialState: { counter: 0, concat: '' }, + reducers: { + // case: meta and error not used in reducer + testDefaultMetaAndError: { + reducer(_, action: PayloadAction) {}, + prepare: (payload: number) => ({ + payload, + meta: 'meta' as 'meta', + error: 'error' as 'error', + }), + }, + // case: meta and error marked as "unknown" in reducer + testUnknownMetaAndError: { + reducer( + _, + action: PayloadAction, + ) {}, + prepare: (payload: number) => ({ + payload, + meta: 'meta' as 'meta', + error: 'error' as 'error', + }), + }, + // case: meta and error are typed in the reducer as returned by prepare + testMetaAndError: { + reducer(_, action: PayloadAction) {}, + prepare: (payload: number) => ({ + payload, + meta: 'meta' as 'meta', + error: 'error' as 'error', + }), + }, + // case: meta is typed differently in the reducer than returned from prepare + testErroneousMeta: { + reducer(_, action: PayloadAction) {}, + // @ts-expect-error + prepare: (payload: number) => ({ + payload, + meta: 1, + error: 'error' as 'error', + }), + }, + // case: error is typed differently in the reducer than returned from prepare + testErroneousError: { + reducer(_, action: PayloadAction) {}, + // @ts-expect-error + prepare: (payload: number) => ({ + payload, + meta: 'meta' as 'meta', + error: 1, + }), + }, + }, + }) + }) + + test('returned case reducer has the correct type', () => { + const counter = createSlice({ + name: 'counter', + initialState: 0, + reducers: { + increment(state, action: PayloadAction) { + return state + action.payload + }, + decrement: { + reducer(state, action: PayloadAction) { + return state - action.payload + }, + prepare(amount: number) { + return { payload: amount } + }, + }, + }, + }) + + test('Should match positively', () => { + expectTypeOf(counter.caseReducers.increment).toMatchTypeOf< + (state: number, action: PayloadAction) => number | void + >() + }) + + test('Should match positively for reducers with prepare callback', () => { + expectTypeOf(counter.caseReducers.decrement).toMatchTypeOf< + (state: number, action: PayloadAction) => number | void + >() + }) + + test("Should not mismatch the payload if it's a simple reducer", () => { + expectTypeOf(counter.caseReducers.increment).not.toMatchTypeOf< + (state: number, action: PayloadAction) => number | void + >() + }) + + test("Should not mismatch the payload if it's a reducer with a prepare callback", () => { + expectTypeOf(counter.caseReducers.decrement).not.toMatchTypeOf< + (state: number, action: PayloadAction) => number | void + >() + }) + + test("Should not include entries that don't exist", () => { + expectTypeOf(counter.caseReducers).not.toHaveProperty( + 'someThingNonExistent', + ) + }) + }) + + test('prepared payload does not match action payload - should cause an error.', () => { + const counter = createSlice({ + name: 'counter', + initialState: { counter: 0 }, + reducers: { + increment: { + reducer(state, action: PayloadAction) { + state.counter += action.payload.length + }, + // @ts-expect-error + prepare(x: string) { + return { + payload: 6, + } + }, + }, + }, + }) + }) + + test('if no Payload Type is specified, accept any payload', () => { + // see https://github.com/reduxjs/redux-toolkit/issues/165 + + const initialState = { + name: null, + } + + const mySlice = createSlice({ + name: 'name', + initialState, + reducers: { + setName: (state, action) => { + state.name = action.payload + }, + }, + }) + + expectTypeOf( + mySlice.actions.setName, + ).toMatchTypeOf() + + const x = mySlice.actions.setName + + mySlice.actions.setName(null) + mySlice.actions.setName('asd') + mySlice.actions.setName(5) + }) + + test('actions.x.match()', () => { + const mySlice = createSlice({ + name: 'name', + initialState: { name: 'test' }, + reducers: { + setName: (state, action: PayloadAction) => { + state.name = action.payload + }, + }, + }) + + const x: Action = {} as any + if (mySlice.actions.setName.match(x)) { + expectTypeOf(x.type).toEqualTypeOf<'name/setName'>() + + expectTypeOf(x.payload).toBeString() + } else { + expectTypeOf(x.type).not.toMatchTypeOf<'name/setName'>() + + expectTypeOf(x).not.toHaveProperty('payload') + } + }) + + test('builder callback for extraReducers', () => { + createSlice({ + name: 'test', + initialState: 0, + reducers: {}, + extraReducers: (builder) => { + expectTypeOf(builder).toEqualTypeOf>() + }, + }) + }) + + test('wrapping createSlice should be possible', () => { + interface GenericState { + data?: T + status: 'loading' | 'finished' | 'error' + } + + const createGenericSlice = < + T, + Reducers extends SliceCaseReducers>, + >({ + name = '', + initialState, + reducers, + }: { + name: string + initialState: GenericState + reducers: ValidateSliceCaseReducers, Reducers> + }) => { + return createSlice({ + name, + initialState, + reducers: { + start(state) { + state.status = 'loading' + }, + success(state: GenericState, action: PayloadAction) { + state.data = action.payload + state.status = 'finished' + }, + ...reducers, + }, + }) + } + + const wrappedSlice = createGenericSlice({ + name: 'test', + initialState: { status: 'loading' } as GenericState, + reducers: { + magic(state) { + expectTypeOf(state).toEqualTypeOf>() + + expectTypeOf(state).not.toMatchTypeOf>() + + state.status = 'finished' + state.data = 'hocus pocus' + }, + }, + }) + + expectTypeOf(wrappedSlice.actions.success).toMatchTypeOf< + ActionCreatorWithPayload + >() + + expectTypeOf(wrappedSlice.actions.magic).toMatchTypeOf< + ActionCreatorWithoutPayload + >() + }) + + test('extraReducers', () => { + interface GenericState { + data: T | null + } + + function createDataSlice< + T, + Reducers extends SliceCaseReducers>, + >( + name: string, + reducers: ValidateSliceCaseReducers, Reducers>, + initialState: GenericState, + ) { + const doNothing = createAction('doNothing') + const setData = createAction('setData') + + const slice = createSlice({ + name, + initialState, + reducers, + extraReducers: (builder) => { + builder.addCase(doNothing, (state) => { + return { ...state } + }) + builder.addCase(setData, (state, { payload }) => { + return { + ...state, + data: payload, + } + }) + }, + }) + return { doNothing, setData, slice } + } + }) + + test('slice selectors', () => { + const sliceWithoutSelectors = createSlice({ + name: '', + initialState: '', + reducers: {}, + }) + + expectTypeOf(sliceWithoutSelectors.selectors).not.toHaveProperty('foo') + + const sliceWithSelectors = createSlice({ + name: 'counter', + initialState: { value: 0 }, + reducers: { + increment: (state) => { + state.value += 1 + }, + }, + selectors: { + selectValue: (state) => state.value, + selectMultiply: (state, multiplier: number) => state.value * multiplier, + selectToFixed: Object.assign( + (state: { value: number }) => state.value.toFixed(2), + { static: true }, + ), + }, + }) + + const rootState = { + [sliceWithSelectors.reducerPath]: sliceWithSelectors.getInitialState(), + } + + const { selectValue, selectMultiply, selectToFixed } = + sliceWithSelectors.selectors + + expectTypeOf(selectValue(rootState)).toBeNumber() + + expectTypeOf(selectMultiply(rootState, 2)).toBeNumber() + + expectTypeOf(selectToFixed(rootState)).toBeString() + + expectTypeOf(selectToFixed.unwrapped.static).toBeBoolean() + + const nestedState = { + nested: rootState, + } + + const nestedSelectors = sliceWithSelectors.getSelectors( + (rootState: typeof nestedState) => rootState.nested.counter, + ) + + expectTypeOf(nestedSelectors.selectValue(nestedState)).toBeNumber() + + expectTypeOf(nestedSelectors.selectMultiply(nestedState, 2)).toBeNumber() + + expectTypeOf(nestedSelectors.selectToFixed(nestedState)).toBeString() + }) + + test('reducer callback', () => { + interface TestState { + foo: string + } + + interface TestArg { + test: string + } + + interface TestReturned { + payload: string + } + + interface TestReject { + cause: string + } + + const slice = createSlice({ + name: 'test', + initialState: {} as TestState, + reducers: (create) => { + const preTypedAsyncThunk = create.asyncThunk.withTypes<{ + rejectValue: TestReject + }>() + + // @ts-expect-error + create.asyncThunk(() => {}) + + // @ts-expect-error + create.asyncThunk.withTypes<{ + rejectValue: string + dispatch: StoreDispatch + }>() + + return { + normalReducer: create.reducer((state, action) => { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.payload).toBeString() + }), + optionalReducer: create.reducer( + (state, action) => { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.payload).toEqualTypeOf() + }, + ), + noActionReducer: create.reducer((state) => { + expectTypeOf(state).toEqualTypeOf() + }), + preparedReducer: create.preparedReducer( + (payload: string) => ({ + payload, + meta: 'meta' as const, + error: 'error' as const, + }), + (state, action) => { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.payload).toBeString() + + expectTypeOf(action.meta).toEqualTypeOf<'meta'>() + + expectTypeOf(action.error).toEqualTypeOf<'error'>() + }, + ), + testInfer: create.asyncThunk( + function payloadCreator(arg: TestArg, api) { + return Promise.resolve({ payload: 'foo' }) + }, + { + pending(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + }, + fulfilled(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + + expectTypeOf(action.payload).toEqualTypeOf() + }, + rejected(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + + expectTypeOf(action.error).toEqualTypeOf() + }, + settled(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + + if (isRejected(action)) { + expectTypeOf(action.error).toEqualTypeOf() + } else { + expectTypeOf(action.payload).toEqualTypeOf() + } + }, + }, + ), + testExplicitType: create.asyncThunk< + TestReturned, + TestArg, + { + rejectValue: TestReject + } + >( + function payloadCreator(arg, api) { + // here would be a circular reference + expectTypeOf(api.getState()).toBeUnknown() + // here would be a circular reference + expectTypeOf(api.dispatch).toMatchTypeOf< + ThunkDispatch + >() + + // so you need to cast inside instead + const getState = api.getState as () => StoreState + const dispatch = api.dispatch as StoreDispatch + + expectTypeOf(arg).toEqualTypeOf() + + expectTypeOf(api.rejectWithValue).toMatchTypeOf< + (value: TestReject) => any + >() + + return Promise.resolve({ payload: 'foo' }) + }, + { + pending(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + }, + fulfilled(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + + expectTypeOf(action.payload).toEqualTypeOf() + }, + rejected(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + + expectTypeOf(action.error).toEqualTypeOf() + + expectTypeOf(action.payload).toEqualTypeOf< + TestReject | undefined + >() + }, + settled(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + + if (isRejected(action)) { + expectTypeOf(action.error).toEqualTypeOf() + + expectTypeOf(action.payload).toEqualTypeOf< + TestReject | undefined + >() + } else { + expectTypeOf(action.payload).toEqualTypeOf() + } + }, + }, + ), + testPreTyped: preTypedAsyncThunk( + function payloadCreator(arg: TestArg, api) { + expectTypeOf(api.rejectWithValue).toMatchTypeOf< + (value: TestReject) => any + >() + + return Promise.resolve({ payload: 'foo' }) + }, + { + pending(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + }, + fulfilled(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + + expectTypeOf(action.payload).toEqualTypeOf() + }, + rejected(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + + expectTypeOf(action.error).toEqualTypeOf() + + expectTypeOf(action.payload).toEqualTypeOf< + TestReject | undefined + >() + }, + settled(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + + if (isRejected(action)) { + expectTypeOf(action.error).toEqualTypeOf() + + expectTypeOf(action.payload).toEqualTypeOf< + TestReject | undefined + >() + } else { + expectTypeOf(action.payload).toEqualTypeOf() + } + }, + }, + ), + } + }, + }) + + const store = configureStore({ reducer: { test: slice.reducer } }) + + type StoreState = ReturnType + + type StoreDispatch = typeof store.dispatch + + expectTypeOf(slice.actions.normalReducer).toMatchTypeOf< + PayloadActionCreator + >() + + expectTypeOf(slice.actions.normalReducer).toBeCallableWith('') + + expectTypeOf(slice.actions.normalReducer).parameters.not.toMatchTypeOf<[]>() + + expectTypeOf(slice.actions.normalReducer).parameters.not.toMatchTypeOf< + [number] + >() + + expectTypeOf(slice.actions.optionalReducer).toMatchTypeOf< + ActionCreatorWithOptionalPayload + >() + + expectTypeOf(slice.actions.optionalReducer).toBeCallableWith() + + expectTypeOf(slice.actions.optionalReducer).toBeCallableWith('') + + expectTypeOf(slice.actions.optionalReducer).parameter(0).not.toBeNumber() + + expectTypeOf( + slice.actions.noActionReducer, + ).toMatchTypeOf() + + expectTypeOf(slice.actions.noActionReducer).toBeCallableWith() + + expectTypeOf(slice.actions.noActionReducer).parameter(0).not.toBeString() + + expectTypeOf(slice.actions.preparedReducer).toEqualTypeOf< + ActionCreatorWithPreparedPayload< + [string], + string, + 'test/preparedReducer', + 'error', + 'meta' + > + >() + + expectTypeOf(slice.actions.testInfer).toEqualTypeOf< + AsyncThunk + >() + + expectTypeOf(slice.actions.testExplicitType).toEqualTypeOf< + AsyncThunk + >() + + type TestInferThunk = AsyncThunk + + expectTypeOf(slice.caseReducers.testInfer.pending).toEqualTypeOf< + CaseReducer> + >() + + expectTypeOf(slice.caseReducers.testInfer.fulfilled).toEqualTypeOf< + CaseReducer> + >() + + expectTypeOf(slice.caseReducers.testInfer.rejected).toEqualTypeOf< + CaseReducer> + >() + }) + + test('wrapping createSlice should be possible, with callback', () => { + interface GenericState { + data?: T + status: 'loading' | 'finished' | 'error' + } + + const createGenericSlice = < + T, + Reducers extends SliceCaseReducers>, + >({ + name = '', + initialState, + reducers, + }: { + name: string + initialState: GenericState + reducers: (create: ReducerCreators>) => Reducers + }) => { + return createSlice({ + name, + initialState, + reducers: (create) => ({ + start: create.reducer((state) => { + state.status = 'loading' + }), + success: create.reducer((state, action) => { + state.data = castDraft(action.payload) + state.status = 'finished' + }), + ...reducers(create), + }), + }) + } + + const wrappedSlice = createGenericSlice({ + name: 'test', + initialState: { status: 'loading' } as GenericState, + reducers: (create) => ({ + magic: create.reducer((state) => { + expectTypeOf(state).toEqualTypeOf>() + + expectTypeOf(state).not.toMatchTypeOf>() + + state.status = 'finished' + state.data = 'hocus pocus' + }), + }), + }) + + expectTypeOf(wrappedSlice.actions.success).toMatchTypeOf< + ActionCreatorWithPayload + >() + + expectTypeOf(wrappedSlice.actions.magic).toMatchTypeOf< + ActionCreatorWithoutPayload + >() + }) + + test('selectSlice', () => { + expectTypeOf(counterSlice.selectSlice({ counter: 0 })).toBeNumber() + + // We use `not.toEqualTypeOf` instead of `not.toMatchTypeOf` + // because `toMatchTypeOf` allows missing properties + expectTypeOf(counterSlice.selectSlice).parameter(0).not.toEqualTypeOf<{}>() + }) + + test('buildCreateSlice', () => { + expectTypeOf(buildCreateSlice()).toEqualTypeOf(createSlice) + + buildCreateSlice({ + // @ts-expect-error not possible to recreate shape because symbol is not exported + creators: { asyncThunk: { [Symbol()]: createAsyncThunk } }, + }) + buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } }) + }) +}) diff --git a/packages/toolkit/src/tests/createSlice.typetest.ts b/packages/toolkit/src/tests/createSlice.typetest.ts deleted file mode 100644 index 9b53b54e46..0000000000 --- a/packages/toolkit/src/tests/createSlice.typetest.ts +++ /dev/null @@ -1,879 +0,0 @@ -import type { - ActionCreatorWithNonInferrablePayload, - ActionCreatorWithOptionalPayload, - ActionCreatorWithPayload, - ActionCreatorWithPreparedPayload, - ActionCreatorWithoutPayload, - ActionReducerMapBuilder, - AsyncThunk, - CaseReducer, - PayloadAction, - PayloadActionCreator, - ReducerCreators, - SerializedError, - SliceCaseReducers, - ThunkDispatch, - ValidateSliceCaseReducers, -} from '@reduxjs/toolkit' -import { - asyncThunkCreator, - buildCreateSlice, - configureStore, - createAction, - createAsyncThunk, - createSlice, - isRejected, -} from '@reduxjs/toolkit' -import { castDraft } from 'immer' -import type { Action, Reducer, UnknownAction } from 'redux' -import { - expectExactType, - expectType, - expectUnknown, -} from './utils/typeTestHelpers' - -/* - * Test: Slice name is strongly typed. - */ - -const counterSlice = createSlice({ - name: 'counter', - initialState: 0, - reducers: { - increment: (state: number, action) => state + action.payload, - decrement: (state: number, action) => state - action.payload, - }, -}) - -const uiSlice = createSlice({ - name: 'ui', - initialState: 0, - reducers: { - goToNext: (state: number, action) => state + action.payload, - goToPrevious: (state: number, action) => state - action.payload, - }, -}) - -const actionCreators = { - [counterSlice.name]: { ...counterSlice.actions }, - [uiSlice.name]: { ...uiSlice.actions }, -} - -expectType(actionCreators.counter) -expectType(actionCreators.ui) - -// @ts-expect-error -const value = actionCreators.anyKey - -/* - * Test: createSlice() infers the returned slice's type. - */ -{ - const firstAction = createAction<{ count: number }>('FIRST_ACTION') - - const slice = createSlice({ - name: 'counter', - initialState: 0, - reducers: { - increment: (state: number, action) => state + action.payload, - decrement: (state: number, action) => state - action.payload, - }, - extraReducers: (builder) => { - builder.addCase( - firstAction, - (state, action) => state + action.payload.count - ) - }, - }) - - /* Reducer */ - - expectType>(slice.reducer) - - // @ts-expect-error - expectType>(slice.reducer) - // @ts-expect-error - expectType>(slice.reducer) - /* Actions */ - - slice.actions.increment(1) - slice.actions.decrement(1) - - // @ts-expect-error - slice.actions.other(1) -} - -/* - * Test: Slice action creator types are inferred. - */ -{ - const counter = createSlice({ - name: 'counter', - initialState: 0, - reducers: { - increment: (state) => state + 1, - decrement: (state, { payload = 1 }: PayloadAction) => - state - payload, - multiply: (state, { payload }: PayloadAction) => - Array.isArray(payload) - ? payload.reduce((acc, val) => acc * val, state) - : state * payload, - addTwo: { - reducer: (s, { payload }: PayloadAction) => s + payload, - prepare: (a: number, b: number) => ({ - payload: a + b, - }), - }, - }, - }) - - expectType(counter.actions.increment) - counter.actions.increment() - - expectType>( - counter.actions.decrement - ) - counter.actions.decrement() - counter.actions.decrement(2) - - expectType>( - counter.actions.multiply - ) - counter.actions.multiply(2) - counter.actions.multiply([2, 3, 4]) - - expectType>( - counter.actions.addTwo - ) - counter.actions.addTwo(1, 2) - - // @ts-expect-error - counter.actions.multiply() - - // @ts-expect-error - counter.actions.multiply('2') - - // @ts-expect-error - counter.actions.addTwo(1) -} - -/* - * Test: Slice action creator types properties are "string" - */ -{ - const counter = createSlice({ - name: 'counter', - initialState: 0, - reducers: { - increment: (state) => state + 1, - decrement: (state) => state - 1, - multiply: (state, { payload }: PayloadAction) => - Array.isArray(payload) - ? payload.reduce((acc, val) => acc * val, state) - : state * payload, - }, - }) - - const s: 'counter/increment' = counter.actions.increment.type - const sa: 'counter/increment' = counter.actions.increment().type - const t: 'counter/decrement' = counter.actions.decrement.type - const ta: 'counter/decrement' = counter.actions.decrement().type - const u: 'counter/multiply' = counter.actions.multiply.type - const ua: 'counter/multiply' = counter.actions.multiply(1).type - - // @ts-expect-error - const y: 'increment' = counter.actions.increment.type -} - -/* - * Test: Slice action creator types are inferred for enhanced reducers. - */ -{ - const counter = createSlice({ - name: 'test', - initialState: { counter: 0, concat: '' }, - reducers: { - incrementByStrLen: { - reducer: (state, action: PayloadAction) => { - state.counter += action.payload - }, - prepare: (payload: string) => ({ - payload: payload.length, - }), - }, - concatMetaStrLen: { - reducer: (state, action: PayloadAction) => { - state.concat += action.payload - }, - prepare: (payload: string) => ({ - payload, - meta: payload.length, - }), - }, - }, - }) - - expectType<'test/incrementByStrLen'>( - counter.actions.incrementByStrLen('test').type - ) - expectType(counter.actions.incrementByStrLen('test').payload) - expectType(counter.actions.concatMetaStrLen('test').payload) - expectType(counter.actions.concatMetaStrLen('test').meta) - - // @ts-expect-error - expectType(counter.actions.incrementByStrLen('test').payload) - - // @ts-expect-error - expectType(counter.actions.concatMetaStrLen('test').meta) -} - -/** - * Test: access meta and error from reducer - */ -{ - const counter = createSlice({ - name: 'test', - initialState: { counter: 0, concat: '' }, - reducers: { - // case: meta and error not used in reducer - testDefaultMetaAndError: { - reducer(_, action: PayloadAction) {}, - prepare: (payload: number) => ({ - payload, - meta: 'meta' as 'meta', - error: 'error' as 'error', - }), - }, - // case: meta and error marked as "unknown" in reducer - testUnknownMetaAndError: { - reducer(_, action: PayloadAction) {}, - prepare: (payload: number) => ({ - payload, - meta: 'meta' as 'meta', - error: 'error' as 'error', - }), - }, - // case: meta and error are typed in the reducer as returned by prepare - testMetaAndError: { - reducer(_, action: PayloadAction) {}, - prepare: (payload: number) => ({ - payload, - meta: 'meta' as 'meta', - error: 'error' as 'error', - }), - }, - // case: meta is typed differently in the reducer than returned from prepare - testErroneousMeta: { - reducer(_, action: PayloadAction) {}, - // @ts-expect-error - prepare: (payload: number) => ({ - payload, - meta: 1, - error: 'error' as 'error', - }), - }, - // case: error is typed differently in the reducer than returned from prepare - testErroneousError: { - reducer(_, action: PayloadAction) {}, - // @ts-expect-error - prepare: (payload: number) => ({ - payload, - meta: 'meta' as 'meta', - error: 1, - }), - }, - }, - }) -} - -/* - * Test: returned case reducer has the correct type - */ -{ - const counter = createSlice({ - name: 'counter', - initialState: 0, - reducers: { - increment(state, action: PayloadAction) { - return state + action.payload - }, - decrement: { - reducer(state, action: PayloadAction) { - return state - action.payload - }, - prepare(amount: number) { - return { payload: amount } - }, - }, - }, - }) - - // Should match positively - expectType<(state: number, action: PayloadAction) => number | void>( - counter.caseReducers.increment - ) - - // Should match positively for reducers with prepare callback - expectType<(state: number, action: PayloadAction) => number | void>( - counter.caseReducers.decrement - ) - - // Should not mismatch the payload if it's a simple reducer - - expectType<(state: number, action: PayloadAction) => number | void>( - // @ts-expect-error - counter.caseReducers.increment - ) - - // Should not mismatch the payload if it's a reducer with a prepare callback - - expectType<(state: number, action: PayloadAction) => number | void>( - // @ts-expect-error - counter.caseReducers.decrement - ) - - // Should not include entries that don't exist - - expectType<(state: number, action: PayloadAction) => number | void>( - // @ts-expect-error - counter.caseReducers.someThingNonExistant - ) -} - -/* - * Test: prepared payload does not match action payload - should cause an error. - */ -{ - const counter = createSlice({ - name: 'counter', - initialState: { counter: 0 }, - reducers: { - increment: { - reducer(state, action: PayloadAction) { - state.counter += action.payload.length - }, - // @ts-expect-error - prepare(x: string) { - return { - payload: 6, - } - }, - }, - }, - }) -} - -/* - * Test: if no Payload Type is specified, accept any payload - * see https://github.com/reduxjs/redux-toolkit/issues/165 - */ -{ - const initialState = { - name: null, - } - - const mySlice = createSlice({ - name: 'name', - initialState, - reducers: { - setName: (state, action) => { - state.name = action.payload - }, - }, - }) - - expectType(mySlice.actions.setName) - - const x = mySlice.actions.setName - - mySlice.actions.setName(null) - mySlice.actions.setName('asd') - mySlice.actions.setName(5) -} - -/** - * Test: actions.x.match() - */ -{ - const mySlice = createSlice({ - name: 'name', - initialState: { name: 'test' }, - reducers: { - setName: (state, action: PayloadAction) => { - state.name = action.payload - }, - }, - }) - - const x: Action = {} as any - if (mySlice.actions.setName.match(x)) { - expectType<'name/setName'>(x.type) - expectType(x.payload) - } else { - // @ts-expect-error - expectType<'name/setName'>(x.type) - // @ts-expect-error - expectType(x.payload) - } -} - -/** Test: builder callback for extraReducers */ -{ - createSlice({ - name: 'test', - initialState: 0, - reducers: {}, - extraReducers: (builder) => { - expectType>(builder) - }, - }) -} - -/** Test: wrapping createSlice should be possible */ -{ - interface GenericState { - data?: T - status: 'loading' | 'finished' | 'error' - } - - const createGenericSlice = < - T, - Reducers extends SliceCaseReducers> - >({ - name = '', - initialState, - reducers, - }: { - name: string - initialState: GenericState - reducers: ValidateSliceCaseReducers, Reducers> - }) => { - return createSlice({ - name, - initialState, - reducers: { - start(state) { - state.status = 'loading' - }, - success(state: GenericState, action: PayloadAction) { - state.data = action.payload - state.status = 'finished' - }, - ...reducers, - }, - }) - } - - const wrappedSlice = createGenericSlice({ - name: 'test', - initialState: { status: 'loading' } as GenericState, - reducers: { - magic(state) { - expectType>(state) - // @ts-expect-error - expectType>(state) - - state.status = 'finished' - state.data = 'hocus pocus' - }, - }, - }) - - expectType>(wrappedSlice.actions.success) - expectType>(wrappedSlice.actions.magic) -} - -{ - interface GenericState { - data: T | null - } - - function createDataSlice< - T, - Reducers extends SliceCaseReducers> - >( - name: string, - reducers: ValidateSliceCaseReducers, Reducers>, - initialState: GenericState - ) { - const doNothing = createAction('doNothing') - const setData = createAction('setData') - - const slice = createSlice({ - name, - initialState, - reducers, - extraReducers: (builder) => { - builder.addCase(doNothing, (state) => { - return { ...state } - }) - builder.addCase(setData, (state, { payload }) => { - return { - ...state, - data: payload, - } - }) - }, - }) - return { doNothing, setData, slice } - } -} - -/** - * Test: slice selectors - */ - -{ - const sliceWithoutSelectors = createSlice({ - name: '', - initialState: '', - reducers: {}, - }) - - // @ts-expect-error - sliceWithoutSelectors.selectors.foo - - const sliceWithSelectors = createSlice({ - name: 'counter', - initialState: { value: 0 }, - reducers: { - increment: (state) => { - state.value += 1 - }, - }, - selectors: { - selectValue: (state) => state.value, - selectMultiply: (state, multiplier: number) => state.value * multiplier, - selectToFixed: Object.assign( - (state: { value: number }) => state.value.toFixed(2), - { static: true } - ), - }, - }) - - const rootState = { - [sliceWithSelectors.reducerPath]: sliceWithSelectors.getInitialState(), - } - - const { selectValue, selectMultiply, selectToFixed } = - sliceWithSelectors.selectors - - expectType(selectValue(rootState)) - expectType(selectMultiply(rootState, 2)) - expectType(selectToFixed(rootState)) - - expectType(selectToFixed.unwrapped.static) - - const nestedState = { - nested: rootState, - } - - const nestedSelectors = sliceWithSelectors.getSelectors( - (rootState: typeof nestedState) => rootState.nested.counter - ) - - expectType(nestedSelectors.selectValue(nestedState)) - expectType(nestedSelectors.selectMultiply(nestedState, 2)) - expectType(nestedSelectors.selectToFixed(nestedState)) -} - -/** - * Test: reducer callback - */ - -{ - interface TestState { - foo: string - } - - interface TestArg { - test: string - } - - interface TestReturned { - payload: string - } - - interface TestReject { - cause: string - } - - const slice = createSlice({ - name: 'test', - initialState: {} as TestState, - reducers: (create) => { - const pretypedAsyncThunk = - create.asyncThunk.withTypes<{ rejectValue: TestReject }>() - - // @ts-expect-error - create.asyncThunk(() => {}) - - // @ts-expect-error - create.asyncThunk.withTypes<{ - rejectValue: string - dispatch: StoreDispatch - }>() - - return { - normalReducer: create.reducer((state, action) => { - expectType(state) - expectType(action.payload) - }), - optionalReducer: create.reducer((state, action) => { - expectType(state) - expectType(action.payload) - }), - noActionReducer: create.reducer((state) => { - expectType(state) - }), - preparedReducer: create.preparedReducer( - (payload: string) => ({ - payload, - meta: 'meta' as const, - error: 'error' as const, - }), - (state, action) => { - expectType(state) - expectType(action.payload) - expectExactType('meta' as const)(action.meta) - expectExactType('error' as const)(action.error) - } - ), - testInfer: create.asyncThunk( - function payloadCreator(arg: TestArg, api) { - return Promise.resolve({ payload: 'foo' }) - }, - { - pending(state, action) { - expectType(state) - expectType(action.meta.arg) - }, - fulfilled(state, action) { - expectType(state) - expectType(action.meta.arg) - expectType(action.payload) - }, - rejected(state, action) { - expectType(state) - expectType(action.meta.arg) - expectType(action.error) - }, - settled(state, action) { - expectType(state) - expectType(action.meta.arg) - if (isRejected(action)) { - expectType(action.error) - } else { - expectType(action.payload) - } - }, - } - ), - testExplicitType: create.asyncThunk< - TestReturned, - TestArg, - { - rejectValue: TestReject - } - >( - function payloadCreator(arg, api) { - // here would be a circular reference - expectUnknown(api.getState()) - // here would be a circular reference - expectType>(api.dispatch) - // so you need to cast inside instead - const getState = api.getState as () => StoreState - const dispatch = api.dispatch as StoreDispatch - expectType(arg) - expectType<(value: TestReject) => any>(api.rejectWithValue) - return Promise.resolve({ payload: 'foo' }) - }, - { - pending(state, action) { - expectType(state) - expectType(action.meta.arg) - }, - fulfilled(state, action) { - expectType(state) - expectType(action.meta.arg) - expectType(action.payload) - }, - rejected(state, action) { - expectType(state) - expectType(action.meta.arg) - expectType(action.error) - expectType(action.payload) - }, - settled(state, action) { - expectType(state) - expectType(action.meta.arg) - if (isRejected(action)) { - expectType(action.error) - expectType(action.payload) - } else { - expectType(action.payload) - } - }, - } - ), - testPretyped: pretypedAsyncThunk( - function payloadCreator(arg: TestArg, api) { - expectType<(value: TestReject) => any>(api.rejectWithValue) - return Promise.resolve({ payload: 'foo' }) - }, - { - pending(state, action) { - expectType(state) - expectType(action.meta.arg) - }, - fulfilled(state, action) { - expectType(state) - expectType(action.meta.arg) - expectType(action.payload) - }, - rejected(state, action) { - expectType(state) - expectType(action.meta.arg) - expectType(action.error) - expectType(action.payload) - }, - settled(state, action) { - expectType(state) - expectType(action.meta.arg) - if (isRejected(action)) { - expectType(action.error) - expectType(action.payload) - } else { - expectType(action.payload) - } - }, - } - ), - } - }, - }) - - const store = configureStore({ reducer: { test: slice.reducer } }) - - type StoreState = ReturnType - type StoreDispatch = typeof store.dispatch - - expectType>(slice.actions.normalReducer) - slice.actions.normalReducer('') - // @ts-expect-error - slice.actions.normalReducer() - // @ts-expect-error - slice.actions.normalReducer(0) - expectType>( - slice.actions.optionalReducer - ) - slice.actions.optionalReducer() - slice.actions.optionalReducer('') - // @ts-expect-error - slice.actions.optionalReducer(0) - - expectType(slice.actions.noActionReducer) - slice.actions.noActionReducer() - // @ts-expect-error - slice.actions.noActionReducer('') - expectType< - ActionCreatorWithPreparedPayload< - [string], - string, - 'test/preparedReducer', - 'error', - 'meta' - > - >(slice.actions.preparedReducer) - expectType>(slice.actions.testInfer) - expectType>( - slice.actions.testExplicitType - ) - { - type TestInferThunk = AsyncThunk - expectType>>( - slice.caseReducers.testInfer.pending - ) - expectType>>( - slice.caseReducers.testInfer.fulfilled - ) - expectType>>( - slice.caseReducers.testInfer.rejected - ) - } -} - -/** Test: wrapping createSlice should be possible, with callback */ -{ - interface GenericState { - data?: T - status: 'loading' | 'finished' | 'error' - } - - const createGenericSlice = < - T, - Reducers extends SliceCaseReducers> - >({ - name = '', - initialState, - reducers, - }: { - name: string - initialState: GenericState - reducers: (create: ReducerCreators>) => Reducers - }) => { - return createSlice({ - name, - initialState, - reducers: (create) => ({ - start: create.reducer((state) => { - state.status = 'loading' - }), - success: create.reducer((state, action) => { - state.data = castDraft(action.payload) - state.status = 'finished' - }), - ...reducers(create), - }), - }) - } - - const wrappedSlice = createGenericSlice({ - name: 'test', - initialState: { status: 'loading' } as GenericState, - reducers: (create) => ({ - magic: create.reducer((state) => { - expectType>(state) - // @ts-expect-error - expectType>(state) - - state.status = 'finished' - state.data = 'hocus pocus' - }), - }), - }) - - expectType>(wrappedSlice.actions.success) - expectType>(wrappedSlice.actions.magic) -} - -/** - * Test: selectSlice - */ -{ - expectType(counterSlice.selectSlice({ counter: 0 })) - // @ts-expect-error - counterSlice.selectSlice({}) -} - -/** - * Test: buildCreateSlice - */ -{ - expectExactType(createSlice)(buildCreateSlice()) - buildCreateSlice({ - // @ts-expect-error not possible to recreate shape because symbol is not exported - creators: { asyncThunk: { [Symbol()]: createAsyncThunk } }, - }) - buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } }) -} diff --git a/packages/toolkit/src/tests/getDefaultEnhancers.test-d.ts b/packages/toolkit/src/tests/getDefaultEnhancers.test-d.ts new file mode 100644 index 0000000000..67d42e99e6 --- /dev/null +++ b/packages/toolkit/src/tests/getDefaultEnhancers.test-d.ts @@ -0,0 +1,143 @@ +import type { StoreEnhancer } from '@reduxjs/toolkit' +import { configureStore } from '@reduxjs/toolkit' + +declare const enhancer1: StoreEnhancer< + { + has1: true + }, + { stateHas1: true } +> + +declare const enhancer2: StoreEnhancer< + { + has2: true + }, + { stateHas2: true } +> + +describe('type tests', () => { + test('prepend single element', () => { + const store = configureStore({ + reducer: () => 0, + enhancers: (gDE) => gDE().prepend(enhancer1), + }) + + expectTypeOf(store.has1).toEqualTypeOf() + + expectTypeOf(store.getState().stateHas1).toEqualTypeOf() + + expectTypeOf(store).not.toHaveProperty('has2') + + expectTypeOf(store.getState()).not.toHaveProperty('stateHas2') + }) + + test('prepend multiple (rest)', () => { + const store = configureStore({ + reducer: () => 0, + enhancers: (gDE) => gDE().prepend(enhancer1, enhancer2), + }) + + expectTypeOf(store.has1).toEqualTypeOf() + + expectTypeOf(store.getState().stateHas1).toEqualTypeOf() + + expectTypeOf(store.has2).toEqualTypeOf() + + expectTypeOf(store.getState().stateHas2).toEqualTypeOf() + + expectTypeOf(store).not.toHaveProperty('has3') + + expectTypeOf(store.getState()).not.toHaveProperty('stateHas3') + }) + + test('prepend multiple (array notation)', () => { + const store = configureStore({ + reducer: () => 0, + enhancers: (gDE) => gDE().prepend([enhancer1, enhancer2] as const), + }) + + expectTypeOf(store.has1).toEqualTypeOf() + + expectTypeOf(store.getState().stateHas1).toEqualTypeOf() + + expectTypeOf(store.has2).toEqualTypeOf() + + expectTypeOf(store.getState().stateHas2).toEqualTypeOf() + + expectTypeOf(store).not.toHaveProperty('has3') + + expectTypeOf(store.getState()).not.toHaveProperty('stateHas3') + }) + + test('concat single element', () => { + const store = configureStore({ + reducer: () => 0, + enhancers: (gDE) => gDE().concat(enhancer1), + }) + + expectTypeOf(store.has1).toEqualTypeOf() + + expectTypeOf(store.getState().stateHas1).toEqualTypeOf() + + expectTypeOf(store).not.toHaveProperty('has2') + + expectTypeOf(store.getState()).not.toHaveProperty('stateHas2') + }) + + test('prepend multiple (rest)', () => { + const store = configureStore({ + reducer: () => 0, + enhancers: (gDE) => gDE().concat(enhancer1, enhancer2), + }) + + expectTypeOf(store.has1).toEqualTypeOf() + + expectTypeOf(store.getState().stateHas1).toEqualTypeOf() + + expectTypeOf(store.has2).toEqualTypeOf() + + expectTypeOf(store.getState().stateHas2).toEqualTypeOf() + + expectTypeOf(store).not.toHaveProperty('has3') + + expectTypeOf(store.getState()).not.toHaveProperty('stateHas3') + }) + + test('concat multiple (array notation)', () => { + const store = configureStore({ + reducer: () => 0, + enhancers: (gDE) => gDE().concat([enhancer1, enhancer2] as const), + }) + + expectTypeOf(store.has1).toEqualTypeOf() + + expectTypeOf(store.getState().stateHas1).toEqualTypeOf() + + expectTypeOf(store.has2).toEqualTypeOf() + + expectTypeOf(store.getState().stateHas2).toEqualTypeOf() + + expectTypeOf(store).not.toHaveProperty('has3') + + expectTypeOf(store.getState()).not.toHaveProperty('stateHas3') + }) + + test('concat and prepend', () => { + const store = configureStore({ + reducer: () => 0, + enhancers: (gDE) => gDE().concat(enhancer1).prepend(enhancer2), + }) + + expectTypeOf(store.has1).toEqualTypeOf() + + expectTypeOf(store.getState().stateHas1).toEqualTypeOf() + + expectTypeOf(store.has2).toEqualTypeOf() + + expectTypeOf(store.getState().stateHas2).toEqualTypeOf() + + expectTypeOf(store).not.toHaveProperty('has3') + + expectTypeOf(store.getState()).not.toHaveProperty('stateHas3') + }) +}) diff --git a/packages/toolkit/src/tests/getDefaultEnhancers.typetest.ts b/packages/toolkit/src/tests/getDefaultEnhancers.typetest.ts deleted file mode 100644 index 7ea38237cc..0000000000 --- a/packages/toolkit/src/tests/getDefaultEnhancers.typetest.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { configureStore } from '@reduxjs/toolkit' -import type { StoreEnhancer } from 'redux' - -declare const expectType: (t: T) => T - -declare const enhancer1: StoreEnhancer< - { - has1: true - }, - { stateHas1: true } -> - -declare const enhancer2: StoreEnhancer< - { - has2: true - }, - { stateHas2: true } -> - -{ - // prepend single element - { - const store = configureStore({ - reducer: () => 0, - enhancers: (gDE) => gDE().prepend(enhancer1), - }) - expectType(store.has1) - expectType(store.getState().stateHas1) - - // @ts-expect-error - expectType(store.has2) - // @ts-expect-error - expectType(store.getState().stateHas2) - } - - // prepend multiple (rest) - { - const store = configureStore({ - reducer: () => 0, - enhancers: (gDE) => gDE().prepend(enhancer1, enhancer2), - }) - expectType(store.has1) - expectType(store.getState().stateHas1) - expectType(store.has2) - expectType(store.getState().stateHas2) - - // @ts-expect-error - expectType(store.has3) - // @ts-expect-error - expectType(store.getState().stateHas3) - } - - // prepend multiple (array notation) - { - const store = configureStore({ - reducer: () => 0, - enhancers: (gDE) => gDE().prepend([enhancer1, enhancer2] as const), - }) - expectType(store.has1) - expectType(store.getState().stateHas1) - expectType(store.has2) - expectType(store.getState().stateHas2) - - // @ts-expect-error - expectType(store.has3) - // @ts-expect-error - expectType(store.getState().stateHas3) - } - - // concat single element - { - const store = configureStore({ - reducer: () => 0, - enhancers: (gDE) => gDE().concat(enhancer1), - }) - expectType(store.has1) - expectType(store.getState().stateHas1) - - // @ts-expect-error - expectType(store.has2) - // @ts-expect-error - expectType(store.getState().stateHas2) - } - - // prepend multiple (rest) - { - const store = configureStore({ - reducer: () => 0, - enhancers: (gDE) => gDE().concat(enhancer1, enhancer2), - }) - expectType(store.has1) - expectType(store.getState().stateHas1) - expectType(store.has2) - expectType(store.getState().stateHas2) - - // @ts-expect-error - expectType(store.has3) - // @ts-expect-error - expectType(store.getState().stateHas3) - } - - // concat multiple (array notation) - { - const store = configureStore({ - reducer: () => 0, - enhancers: (gDE) => gDE().concat([enhancer1, enhancer2] as const), - }) - expectType(store.has1) - expectType(store.getState().stateHas1) - expectType(store.has2) - expectType(store.getState().stateHas2) - - // @ts-expect-error - expectType(store.has3) - // @ts-expect-error - expectType(store.getState().stateHas3) - } - - // concat and prepend - { - const store = configureStore({ - reducer: () => 0, - enhancers: (gDE) => gDE().concat(enhancer1).prepend(enhancer2), - }) - expectType(store.has1) - expectType(store.getState().stateHas1) - expectType(store.has2) - expectType(store.getState().stateHas2) - - // @ts-expect-error - expectType(store.has3) - // @ts-expect-error - expectType(store.getState().stateHas3) - } -} diff --git a/packages/toolkit/src/tests/getDefaultMiddleware.test-d.ts b/packages/toolkit/src/tests/getDefaultMiddleware.test-d.ts new file mode 100644 index 0000000000..50680a09ce --- /dev/null +++ b/packages/toolkit/src/tests/getDefaultMiddleware.test-d.ts @@ -0,0 +1,199 @@ +import { buildGetDefaultMiddleware } from '@internal/getDefaultMiddleware' +import type { + Action, + Dispatch, + Middleware, + ThunkAction, + ThunkDispatch, + ThunkMiddleware, + Tuple, + UnknownAction, +} from '@reduxjs/toolkit' +import { configureStore } from '@reduxjs/toolkit' + +declare const middleware1: Middleware<{ + (_: string): number +}> + +declare const middleware2: Middleware<{ + (_: number): string +}> + +type ThunkReturn = Promise<'thunk'> +declare const thunkCreator: () => () => ThunkReturn + +const getDefaultMiddleware = buildGetDefaultMiddleware() + +describe('type tests', () => { + test('prepend single element', () => { + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().prepend(middleware1), + }) + + expectTypeOf(store.dispatch('foo')).toBeNumber() + + expectTypeOf(store.dispatch(thunkCreator())).toEqualTypeOf() + + expectTypeOf(store.dispatch('foo')).not.toBeString() + }) + + test('prepend multiple (rest)', () => { + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().prepend(middleware1, middleware2), + }) + + expectTypeOf(store.dispatch('foo')).toBeNumber() + + expectTypeOf(store.dispatch(5)).toBeString() + + expectTypeOf(store.dispatch(thunkCreator())).toEqualTypeOf() + + expectTypeOf(store.dispatch('foo')).not.toBeString() + }) + + test('prepend multiple (array notation)', () => { + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().prepend([middleware1, middleware2] as const), + }) + + expectTypeOf(store.dispatch('foo')).toBeNumber() + + expectTypeOf(store.dispatch(5)).toBeString() + + expectTypeOf(store.dispatch(thunkCreator())).toEqualTypeOf() + + expectTypeOf(store.dispatch('foo')).not.toBeString() + }) + + test('concat single element', () => { + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().concat(middleware1), + }) + + expectTypeOf(store.dispatch('foo')).toBeNumber() + + expectTypeOf(store.dispatch(thunkCreator())).toEqualTypeOf() + + expectTypeOf(store.dispatch('foo')).not.toBeString() + }) + + test('prepend multiple (rest)', () => { + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().concat(middleware1, middleware2), + }) + + expectTypeOf(store.dispatch('foo')).toBeNumber() + + expectTypeOf(store.dispatch(5)).toBeString() + + expectTypeOf(store.dispatch(thunkCreator())).toEqualTypeOf() + + expectTypeOf(store.dispatch('foo')).not.toBeString() + }) + + test('concat multiple (array notation)', () => { + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().concat([middleware1, middleware2] as const), + }) + + expectTypeOf(store.dispatch('foo')).toBeNumber() + + expectTypeOf(store.dispatch(5)).toBeString() + + expectTypeOf(store.dispatch(thunkCreator())).toEqualTypeOf() + + expectTypeOf(store.dispatch('foo')).not.toBeString() + }) + + test('concat and prepend', () => { + const store = configureStore({ + reducer: () => 0, + middleware: (gDM) => gDM().concat(middleware1).prepend(middleware2), + }) + + expectTypeOf(store.dispatch('foo')).toBeNumber() + + expectTypeOf(store.dispatch(5)).toBeString() + + expectTypeOf(store.dispatch(thunkCreator())).toEqualTypeOf() + + expectTypeOf(store.dispatch('foo')).not.toBeString() + }) + + test('allows passing options to thunk', () => { + const extraArgument = 42 as const + + const m2 = getDefaultMiddleware({ + thunk: false, + }) + + expectTypeOf(m2).toMatchTypeOf>() + + const dummyMiddleware: Middleware< + { + (action: Action<'actionListenerMiddleware/add'>): () => void + }, + { counter: number } + > = (storeApi) => (next) => (action) => { + return next(action) + } + + const dummyMiddleware2: Middleware<{}, { counter: number }> = + (storeApi) => (next) => (action) => {} + + const testThunk: ThunkAction< + void, + { counter: number }, + number, + UnknownAction + > = (dispatch, getState, extraArg) => { + expect(extraArg).toBe(extraArgument) + } + + const reducer = () => ({ counter: 123 }) + + const store = configureStore({ + reducer, + middleware: (gDM) => { + const middleware = gDM({ + thunk: { extraArgument }, + immutableCheck: false, + serializableCheck: false, + actionCreatorCheck: false, + }) + + const m3 = middleware.concat(dummyMiddleware, dummyMiddleware2) + + expectTypeOf(m3).toMatchTypeOf< + Tuple< + [ + ThunkMiddleware, + Middleware< + (action: Action<'actionListenerMiddleware/add'>) => () => void, + { + counter: number + }, + Dispatch + >, + Middleware<{}, any, Dispatch>, + ] + > + >() + + return m3 + }, + }) + + expectTypeOf(store.dispatch).toMatchTypeOf< + ThunkDispatch & Dispatch + >() + + store.dispatch(testThunk) + }) +}) diff --git a/packages/toolkit/src/tests/getDefaultMiddleware.test.ts b/packages/toolkit/src/tests/getDefaultMiddleware.test.ts index d32def1019..b72a959b0b 100644 --- a/packages/toolkit/src/tests/getDefaultMiddleware.test.ts +++ b/packages/toolkit/src/tests/getDefaultMiddleware.test.ts @@ -1,20 +1,15 @@ +import { Tuple } from '@internal/utils' import type { Action, - Dispatch, Middleware, ThunkAction, - ThunkDispatch, UnknownAction, } from '@reduxjs/toolkit' import { configureStore } from '@reduxjs/toolkit' -import type { ThunkMiddleware } from 'redux-thunk' import { thunk } from 'redux-thunk' import { vi } from 'vitest' -import { expectType } from './utils/typeTestHelpers' - import { buildGetDefaultMiddleware } from '@internal/getDefaultMiddleware' -import { Tuple } from '@internal/utils' const getDefaultMiddleware = buildGetDefaultMiddleware() @@ -80,8 +75,6 @@ describe('getDefaultMiddleware', () => { thunk: false, }) - expectType>(m2) - const dummyMiddleware: Middleware< { (action: Action<'actionListenerMiddleware/add'>): () => void @@ -117,30 +110,10 @@ describe('getDefaultMiddleware', () => { const m3 = middleware.concat(dummyMiddleware, dummyMiddleware2) - expectType< - Tuple< - [ - ThunkMiddleware, - Middleware< - (action: Action<'actionListenerMiddleware/add'>) => () => void, - { - counter: number - }, - Dispatch - >, - Middleware<{}, any, Dispatch> - ] - > - >(m3) - return m3 }, }) - expectType & Dispatch>( - store.dispatch - ) - store.dispatch(testThunk) }) diff --git a/packages/toolkit/src/tests/getDefaultMiddleware.typetest.ts b/packages/toolkit/src/tests/getDefaultMiddleware.typetest.ts deleted file mode 100644 index 160f1260c4..0000000000 --- a/packages/toolkit/src/tests/getDefaultMiddleware.typetest.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { configureStore } from '@reduxjs/toolkit' -import type { Middleware } from 'redux' - -declare const expectType: (t: T) => T - -declare const middleware1: Middleware<{ - (_: string): number -}> - -declare const middleware2: Middleware<{ - (_: number): string -}> - -type ThunkReturn = Promise<'thunk'> -declare const thunkCreator: () => () => ThunkReturn - -{ - // prepend single element - { - const store = configureStore({ - reducer: () => 0, - middleware: (gDM) => gDM().prepend(middleware1), - }) - expectType(store.dispatch('foo')) - expectType(store.dispatch(thunkCreator())) - - // @ts-expect-error - expectType(store.dispatch('foo')) - } - - // prepend multiple (rest) - { - const store = configureStore({ - reducer: () => 0, - middleware: (gDM) => gDM().prepend(middleware1, middleware2), - }) - expectType(store.dispatch('foo')) - expectType(store.dispatch(5)) - expectType(store.dispatch(thunkCreator())) - - // @ts-expect-error - expectType(store.dispatch('foo')) - } - - // prepend multiple (array notation) - { - const store = configureStore({ - reducer: () => 0, - middleware: (gDM) => gDM().prepend([middleware1, middleware2] as const), - }) - - expectType(store.dispatch('foo')) - expectType(store.dispatch(5)) - expectType(store.dispatch(thunkCreator())) - - // @ts-expect-error - expectType(store.dispatch('foo')) - } - - // concat single element - { - const store = configureStore({ - reducer: () => 0, - middleware: (gDM) => gDM().concat(middleware1), - }) - - expectType(store.dispatch('foo')) - expectType(store.dispatch(thunkCreator())) - - // @ts-expect-error - expectType(store.dispatch('foo')) - } - - // prepend multiple (rest) - { - const store = configureStore({ - reducer: () => 0, - middleware: (gDM) => gDM().concat(middleware1, middleware2), - }) - - expectType(store.dispatch('foo')) - expectType(store.dispatch(5)) - expectType(store.dispatch(thunkCreator())) - - // @ts-expect-error - expectType(store.dispatch('foo')) - } - - // concat multiple (array notation) - { - const store = configureStore({ - reducer: () => 0, - middleware: (gDM) => gDM().concat([middleware1, middleware2] as const), - }) - - expectType(store.dispatch('foo')) - expectType(store.dispatch(5)) - expectType(store.dispatch(thunkCreator())) - - // @ts-expect-error - expectType(store.dispatch('foo')) - } - - // concat and prepend - { - const store = configureStore({ - reducer: () => 0, - middleware: (gDM) => gDM().concat(middleware1).prepend(middleware2), - }) - - expectType(store.dispatch('foo')) - expectType(store.dispatch(5)) - expectType(store.dispatch(thunkCreator())) - - // @ts-expect-error - expectType(store.dispatch('foo')) - } -} diff --git a/packages/toolkit/src/tests/mapBuilders.test-d.ts b/packages/toolkit/src/tests/mapBuilders.test-d.ts new file mode 100644 index 0000000000..63aece1c18 --- /dev/null +++ b/packages/toolkit/src/tests/mapBuilders.test-d.ts @@ -0,0 +1,268 @@ +import type { SerializedError } from '@internal/createAsyncThunk' +import { createAsyncThunk } from '@internal/createAsyncThunk' +import { executeReducerBuilderCallback } from '@internal/mapBuilders' +import type { UnknownAction } from '@reduxjs/toolkit' +import { createAction } from '@reduxjs/toolkit' + +describe('type tests', () => { + test('builder callback for actionMap', () => { + const increment = createAction('increment') + + const decrement = createAction('decrement') + + executeReducerBuilderCallback((builder) => { + builder.addCase(increment, (state, action) => { + expectTypeOf(state).toBeNumber() + + expectTypeOf(action).toEqualTypeOf<{ + type: 'increment' + payload: number + }>() + + expectTypeOf(state).not.toBeString() + + expectTypeOf(action).not.toMatchTypeOf<{ + type: 'increment' + payload: string + }>() + + expectTypeOf(action).not.toMatchTypeOf<{ + type: 'decrement' + payload: number + }>() + }) + + builder.addCase('increment', (state, action) => { + expectTypeOf(state).toBeNumber() + + expectTypeOf(action).toEqualTypeOf<{ type: 'increment' }>() + + expectTypeOf(state).not.toBeString() + + expectTypeOf(action).not.toMatchTypeOf<{ type: 'decrement' }>() + + // this cannot be inferred and has to be manually specified + expectTypeOf(action).not.toMatchTypeOf<{ + type: 'increment' + payload: number + }>() + }) + + builder.addCase( + increment, + (state, action: ReturnType) => state, + ) + + // @ts-expect-error + builder.addCase( + increment, + (state, action: ReturnType) => state, + ) + + builder.addCase( + 'increment', + (state, action: ReturnType) => state, + ) + + // @ts-expect-error + builder.addCase( + 'decrement', + (state, action: ReturnType) => state, + ) + + // action type is inferred + builder.addMatcher(increment.match, (state, action) => { + expectTypeOf(action).toEqualTypeOf>() + }) + + test('action type is inferred when type predicate lacks `type` property', () => { + type PredicateWithoutTypeProperty = { + payload: number + } + + builder.addMatcher( + (action): action is PredicateWithoutTypeProperty => true, + (state, action) => { + expectTypeOf(action).toMatchTypeOf() + + expectTypeOf(action).toMatchTypeOf() + }, + ) + }) + + // action type defaults to UnknownAction if no type predicate matcher is passed + builder.addMatcher( + () => true, + (state, action) => { + expectTypeOf(action).toMatchTypeOf() + }, + ) + + // with a boolean checker, action can also be typed by type argument + builder.addMatcher<{ foo: boolean }>( + () => true, + (state, action) => { + expectTypeOf(action).toMatchTypeOf<{ foo: boolean }>() + + expectTypeOf(action).toMatchTypeOf() + }, + ) + + // addCase().addMatcher() is possible, action type inferred correctly + builder + .addCase( + 'increment', + (state, action: ReturnType) => state, + ) + .addMatcher(decrement.match, (state, action) => { + expectTypeOf(action).toEqualTypeOf>() + }) + + // addCase().addDefaultCase() is possible, action type is UnknownAction + builder + .addCase( + 'increment', + (state, action: ReturnType) => state, + ) + .addDefaultCase((state, action) => { + expectTypeOf(action).toMatchTypeOf() + }) + + test('addMatcher() should prevent further calls to addCase()', () => { + const b = builder.addMatcher(increment.match, () => {}) + + expectTypeOf(b).not.toHaveProperty('addCase') + + expectTypeOf(b.addMatcher).toBeCallableWith(increment.match, () => {}) + + expectTypeOf(b.addDefaultCase).toBeCallableWith(() => {}) + }) + + test('addDefaultCase() should prevent further calls to addCase(), addMatcher() and addDefaultCase', () => { + const b = builder.addDefaultCase(() => {}) + + expectTypeOf(b).not.toHaveProperty('addCase') + + expectTypeOf(b).not.toHaveProperty('addMatcher') + + expectTypeOf(b).not.toHaveProperty('addDefaultCase') + }) + + describe('`createAsyncThunk` actions work with `mapBuilder`', () => { + test('case 1: normal `createAsyncThunk`', () => { + const thunk = createAsyncThunk('test', () => { + return 'ret' as const + }) + builder.addCase(thunk.pending, (_, action) => { + expectTypeOf(action).toMatchTypeOf<{ + payload: undefined + meta: { + arg: void + requestId: string + requestStatus: 'pending' + } + }>() + }) + + builder.addCase(thunk.rejected, (_, action) => { + expectTypeOf(action).toMatchTypeOf<{ + payload: unknown + error: SerializedError + meta: { + arg: void + requestId: string + requestStatus: 'rejected' + aborted: boolean + condition: boolean + rejectedWithValue: boolean + } + }>() + }) + builder.addCase(thunk.fulfilled, (_, action) => { + expectTypeOf(action).toMatchTypeOf<{ + payload: 'ret' + meta: { + arg: void + requestId: string + requestStatus: 'fulfilled' + } + }>() + }) + }) + }) + + test('case 2: `createAsyncThunk` with `meta`', () => { + const thunk = createAsyncThunk< + 'ret', + void, + { + pendingMeta: { startedTimeStamp: number } + fulfilledMeta: { + fulfilledTimeStamp: number + baseQueryMeta: 'meta!' + } + rejectedMeta: { + baseQueryMeta: 'meta!' + } + } + >( + 'test', + (_, api) => { + return api.fulfillWithValue('ret' as const, { + fulfilledTimeStamp: 5, + baseQueryMeta: 'meta!', + }) + }, + { + getPendingMeta() { + return { startedTimeStamp: 0 } + }, + }, + ) + + builder.addCase(thunk.pending, (_, action) => { + expectTypeOf(action).toMatchTypeOf<{ + payload: undefined + meta: { + arg: void + requestId: string + requestStatus: 'pending' + startedTimeStamp: number + } + }>() + }) + + builder.addCase(thunk.rejected, (_, action) => { + expectTypeOf(action).toMatchTypeOf<{ + payload: unknown + error: SerializedError + meta: { + arg: void + requestId: string + requestStatus: 'rejected' + aborted: boolean + condition: boolean + rejectedWithValue: boolean + baseQueryMeta?: 'meta!' + } + }>() + + if (action.meta.rejectedWithValue) { + expectTypeOf(action.meta.baseQueryMeta).toEqualTypeOf<'meta!'>() + } + }) + builder.addCase(thunk.fulfilled, (_, action) => { + expectTypeOf(action).toMatchTypeOf<{ + payload: 'ret' + meta: { + arg: void + requestId: string + requestStatus: 'fulfilled' + baseQueryMeta: 'meta!' + } + }>() + }) + }) + }) + }) +}) diff --git a/packages/toolkit/src/tests/mapBuilders.typetest.ts b/packages/toolkit/src/tests/mapBuilders.typetest.ts deleted file mode 100644 index dec653378e..0000000000 --- a/packages/toolkit/src/tests/mapBuilders.typetest.ts +++ /dev/null @@ -1,248 +0,0 @@ -import type { SerializedError } from '@internal/createAsyncThunk' -import { createAsyncThunk } from '@internal/createAsyncThunk' -import { executeReducerBuilderCallback } from '@internal/mapBuilders' -import type { UnknownAction } from '@reduxjs/toolkit' -import { createAction } from '@reduxjs/toolkit' -import { expectExactType, expectType } from './utils/typeTestHelpers' - -/** Test: builder callback for actionMap */ -{ - const increment = createAction('increment') - const decrement = createAction('decrement') - - executeReducerBuilderCallback((builder) => { - builder.addCase(increment, (state, action) => { - expectType(state) - expectType<{ type: 'increment'; payload: number }>(action) - // @ts-expect-error - expectType(state) - // @ts-expect-error - expectType<{ type: 'increment'; payload: string }>(action) - // @ts-expect-error - expectType<{ type: 'decrement'; payload: number }>(action) - }) - - builder.addCase('increment', (state, action) => { - expectType(state) - expectType<{ type: 'increment' }>(action) - // @ts-expect-error - expectType<{ type: 'decrement' }>(action) - // @ts-expect-error - this cannot be inferred and has to be manually specified - expectType<{ type: 'increment'; payload: number }>(action) - }) - - builder.addCase( - increment, - (state, action: ReturnType) => state - ) - // @ts-expect-error - builder.addCase( - increment, - (state, action: ReturnType) => state - ) - - builder.addCase( - 'increment', - (state, action: ReturnType) => state - ) - // @ts-expect-error - builder.addCase( - 'decrement', - (state, action: ReturnType) => state - ) - - // action type is inferred - builder.addMatcher(increment.match, (state, action) => { - expectType>(action) - }) - - { - // action type is inferred when type predicate lacks `type` property - type PredicateWithoutTypeProperty = { - payload: number - } - - builder.addMatcher( - (action): action is PredicateWithoutTypeProperty => true, - (state, action) => { - expectType(action) - expectType(action) - } - ) - } - - // action type defaults to UnknownAction if no type predicate matcher is passed - builder.addMatcher( - () => true, - (state, action) => { - expectExactType({} as UnknownAction)(action) - } - ) - - // with a boolean checker, action can also be typed by type argument - builder.addMatcher<{ foo: boolean }>( - () => true, - (state, action) => { - expectType<{ foo: boolean }>(action) - expectType(action) - } - ) - - // addCase().addMatcher() is possible, action type inferred correctly - builder - .addCase( - 'increment', - (state, action: ReturnType) => state - ) - .addMatcher(decrement.match, (state, action) => { - expectType>(action) - }) - - // addCase().addDefaultCase() is possible, action type is UnknownAction - builder - .addCase( - 'increment', - (state, action: ReturnType) => state - ) - .addDefaultCase((state, action) => { - expectType(action) - }) - - { - // addMatcher() should prevent further calls to addCase() - const b = builder.addMatcher(increment.match, () => {}) - // @ts-expect-error - b.addCase(increment, () => {}) - b.addMatcher(increment.match, () => {}) - b.addDefaultCase(() => {}) - } - - { - // addDefaultCase() should prevent further calls to addCase(), addMatcher() and addDefaultCase - const b = builder.addDefaultCase(() => {}) - // @ts-expect-error - b.addCase(increment, () => {}) - // @ts-expect-error - b.addMatcher(increment.match, () => {}) - // @ts-expect-error - b.addDefaultCase(() => {}) - } - - // `createAsyncThunk` actions work with `mapBuilder` - { - // case 1: normal `createAsyncThunk` - { - const thunk = createAsyncThunk('test', () => { - return 'ret' as const - }) - builder.addCase(thunk.pending, (_, action) => { - expectType<{ - payload: undefined - meta: { - arg: void - requestId: string - requestStatus: 'pending' - } - }>(action) - }) - - builder.addCase(thunk.rejected, (_, action) => { - expectType<{ - payload: unknown - error: SerializedError - meta: { - arg: void - requestId: string - requestStatus: 'rejected' - aborted: boolean - condition: boolean - rejectedWithValue: boolean - } - }>(action) - }) - builder.addCase(thunk.fulfilled, (_, action) => { - expectType<{ - payload: 'ret' - meta: { - arg: void - requestId: string - requestStatus: 'fulfilled' - } - }>(action) - }) - } - } - // case 2: `createAsyncThunk` with `meta` - { - const thunk = createAsyncThunk< - 'ret', - void, - { - pendingMeta: { startedTimeStamp: number } - fulfilledMeta: { - fulfilledTimeStamp: number - baseQueryMeta: 'meta!' - } - rejectedMeta: { - baseQueryMeta: 'meta!' - } - } - >( - 'test', - (_, api) => { - return api.fulfillWithValue('ret' as const, { - fulfilledTimeStamp: 5, - baseQueryMeta: 'meta!', - }) - }, - { - getPendingMeta() { - return { startedTimeStamp: 0 } - }, - } - ) - - builder.addCase(thunk.pending, (_, action) => { - expectType<{ - payload: undefined - meta: { - arg: void - requestId: string - requestStatus: 'pending' - startedTimeStamp: number - } - }>(action) - }) - - builder.addCase(thunk.rejected, (_, action) => { - expectType<{ - payload: unknown - error: SerializedError - meta: { - arg: void - requestId: string - requestStatus: 'rejected' - aborted: boolean - condition: boolean - rejectedWithValue: boolean - baseQueryMeta?: 'meta!' - } - }>(action) - if (action.meta.rejectedWithValue) { - expectType<'meta!'>(action.meta.baseQueryMeta) - } - }) - builder.addCase(thunk.fulfilled, (_, action) => { - expectType<{ - payload: 'ret' - meta: { - arg: void - requestId: string - requestStatus: 'fulfilled' - baseQueryMeta: 'meta!' - } - }>(action) - }) - } - }) -} diff --git a/packages/toolkit/src/tests/matchers.test-d.ts b/packages/toolkit/src/tests/matchers.test-d.ts new file mode 100644 index 0000000000..269debcfd8 --- /dev/null +++ b/packages/toolkit/src/tests/matchers.test-d.ts @@ -0,0 +1,302 @@ +import type { UnknownAction } from 'redux' +import type { SerializedError } from '../../src' +import { + createAction, + createAsyncThunk, + isAllOf, + isAnyOf, + isAsyncThunkAction, + isFulfilled, + isPending, + isRejected, + isRejectedWithValue, +} from '../../src' + +const action: UnknownAction = { type: 'foo' } + +describe('type tests', () => { + describe('isAnyOf', () => { + test('isAnyOf correctly narrows types when used with action creators', () => { + const actionA = createAction('a', () => { + return { + payload: { + prop1: 1, + prop3: 2, + }, + } + }) + + const actionB = createAction('b', () => { + return { + payload: { + prop1: 1, + prop2: 2, + }, + } + }) + + if (isAnyOf(actionA, actionB)(action)) { + return { + prop1: action.payload.prop1, + // @ts-expect-error + prop2: action.payload.prop2, + // @ts-expect-error + prop3: action.payload.prop3, + } + } + }) + + test('isAnyOf correctly narrows types when used with async thunks', () => { + const asyncThunk1 = createAsyncThunk<{ prop1: number; prop3: number }>( + 'asyncThunk1', + + async () => { + return { + prop1: 1, + prop3: 3, + } + } + ) + + const asyncThunk2 = createAsyncThunk<{ prop1: number; prop2: number }>( + 'asyncThunk2', + + async () => { + return { + prop1: 1, + prop2: 2, + } + } + ) + + if (isAnyOf(asyncThunk1.fulfilled, asyncThunk2.fulfilled)(action)) { + return { + prop1: action.payload.prop1, + // @ts-expect-error + prop2: action.payload.prop2, + // @ts-expect-error + prop3: action.payload.prop3, + } + } + }) + + test('isAnyOf correctly narrows types when used with type guards', () => { + interface ActionA { + type: 'a' + payload: { + prop1: 1 + prop3: 2 + } + } + + interface ActionB { + type: 'b' + payload: { + prop1: 1 + prop2: 2 + } + } + + const guardA = (v: any): v is ActionA => { + return v.type === 'a' + } + + const guardB = (v: any): v is ActionB => { + return v.type === 'b' + } + + if (isAnyOf(guardA, guardB)(action)) { + return { + prop1: action.payload.prop1, + // @ts-expect-error + prop2: action.payload.prop2, + // @ts-expect-error + prop3: action.payload.prop3, + } + } + }) + }) + + describe('isAllOf', () => { + interface SpecialAction { + payload: { + special: boolean + } + } + + const isSpecialAction = (v: any): v is SpecialAction => { + return v.meta.isSpecial + } + + test('isAllOf correctly narrows types when used with action creators and type guards', () => { + const actionA = createAction('a', () => { + return { + payload: { + prop1: 1, + prop3: 2, + }, + } + }) + + if (isAllOf(actionA, isSpecialAction)(action)) { + return { + prop1: action.payload.prop1, + // @ts-expect-error + prop2: action.payload.prop2, + prop3: action.payload.prop3, + special: action.payload.special, + } + } + }) + + test('isAllOf correctly narrows types when used with async thunks and type guards', () => { + const asyncThunk1 = createAsyncThunk<{ prop1: number; prop3: number }>( + 'asyncThunk1', + + async () => { + return { + prop1: 1, + prop3: 3, + } + } + ) + + if (isAllOf(asyncThunk1.fulfilled, isSpecialAction)(action)) { + return { + prop1: action.payload.prop1, + // @ts-expect-error + prop2: action.payload.prop2, + prop3: action.payload.prop3, + special: action.payload.special, + } + } + }) + + test('isAnyOf correctly narrows types when used with type guards', () => { + interface ActionA { + type: 'a' + payload: { + prop1: 1 + prop3: 2 + } + } + + const guardA = (v: any): v is ActionA => { + return v.type === 'a' + } + + if (isAllOf(guardA, isSpecialAction)(action)) { + return { + prop1: action.payload.prop1, + // @ts-expect-error + prop2: action.payload.prop2, + prop3: action.payload.prop3, + special: action.payload.special, + } + } + }) + + test('isPending correctly narrows types', () => { + if (isPending(action)) { + expectTypeOf(action.payload).toBeUndefined() + + expectTypeOf(action).not.toHaveProperty('error') + } + + const thunk = createAsyncThunk('a', () => 'result') + + if (isPending(thunk)(action)) { + expectTypeOf(action.payload).toBeUndefined() + + expectTypeOf(action).not.toHaveProperty('error') + } + }) + + test('isRejected correctly narrows types', () => { + if (isRejected(action)) { + // might be there if rejected with payload + expectTypeOf(action.payload).toBeUnknown() + + expectTypeOf(action.error).toEqualTypeOf() + } + + const thunk = createAsyncThunk('a', () => 'result') + + if (isRejected(thunk)(action)) { + // might be there if rejected with payload + expectTypeOf(action.payload).toBeUnknown() + + expectTypeOf(action.error).toEqualTypeOf() + } + }) + + test('isFulfilled correctly narrows types', () => { + if (isFulfilled(action)) { + expectTypeOf(action.payload).toBeUnknown() + + expectTypeOf(action).not.toHaveProperty('error') + } + + const thunk = createAsyncThunk('a', () => 'result') + if (isFulfilled(thunk)(action)) { + expectTypeOf(action.payload).toBeString() + + expectTypeOf(action).not.toHaveProperty('error') + } + }) + + test('isAsyncThunkAction correctly narrows types', () => { + if (isAsyncThunkAction(action)) { + expectTypeOf(action.payload).toBeUnknown() + + // do not expect an error property because pending/fulfilled lack it + expectTypeOf(action).not.toHaveProperty('error') + } + + const thunk = createAsyncThunk('a', () => 'result') + if (isAsyncThunkAction(thunk)(action)) { + // we should expect the payload to be available, but of unknown type because the action may be pending/rejected + expectTypeOf(action.payload).toBeUnknown() + + // do not expect an error property because pending/fulfilled lack it + expectTypeOf(action).not.toHaveProperty('error') + } + }) + + test('isRejectedWithValue correctly narrows types', () => { + if (isRejectedWithValue(action)) { + expectTypeOf(action.payload).toBeUnknown() + + expectTypeOf(action.error).toEqualTypeOf() + } + + const thunk = createAsyncThunk< + string, + void, + { rejectValue: { message: string } } + >('a', () => 'result') + if (isRejectedWithValue(thunk)(action)) { + expectTypeOf(action.payload).toEqualTypeOf({ message: '' as string }) + + expectTypeOf(action.error).toEqualTypeOf() + } + }) + }) + + test('matchersAcceptSpreadArguments', () => { + const thunk1 = createAsyncThunk('a', () => 'a') + const thunk2 = createAsyncThunk('b', () => 'b') + const interestingThunks = [thunk1, thunk2] + const interestingPendingThunks = interestingThunks.map( + (thunk) => thunk.pending + ) + const interestingFulfilledThunks = interestingThunks.map( + (thunk) => thunk.fulfilled + ) + + const isLoading = isAnyOf(...interestingPendingThunks) + const isNotLoading = isAnyOf(...interestingFulfilledThunks) + + const isAllLoading = isAllOf(...interestingPendingThunks) + }) +}) diff --git a/packages/toolkit/src/tests/matchers.typetest.ts b/packages/toolkit/src/tests/matchers.typetest.ts deleted file mode 100644 index 2cd8cd31ce..0000000000 --- a/packages/toolkit/src/tests/matchers.typetest.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { expectExactType, expectUnknown } from './utils/typeTestHelpers' -import type { UnknownAction } from 'redux' -import type { SerializedError } from '../../src' -import { - createAction, - createAsyncThunk, - isAllOf, - isAnyOf, - isAsyncThunkAction, - isFulfilled, - isPending, - isRejected, - isRejectedWithValue, -} from '../../src' - -/* isAnyOf */ - -/* - * Test: isAnyOf correctly narrows types when used with action creators - */ -function isAnyOfActionTest(action: UnknownAction) { - const actionA = createAction('a', () => { - return { - payload: { - prop1: 1, - prop3: 2, - }, - } - }) - - const actionB = createAction('b', () => { - return { - payload: { - prop1: 1, - prop2: 2, - }, - } - }) - - if (isAnyOf(actionA, actionB)(action)) { - return { - prop1: action.payload.prop1, - // @ts-expect-error - prop2: action.payload.prop2, - // @ts-expect-error - prop3: action.payload.prop3, - } - } -} - -/* - * Test: isAnyOf correctly narrows types when used with async thunks - */ -function isAnyOfThunkTest(action: UnknownAction) { - const asyncThunk1 = createAsyncThunk<{ prop1: number; prop3: number }>( - 'asyncThunk1', - - async () => { - return { - prop1: 1, - prop3: 3, - } - } - ) - - const asyncThunk2 = createAsyncThunk<{ prop1: number; prop2: number }>( - 'asyncThunk2', - - async () => { - return { - prop1: 1, - prop2: 2, - } - } - ) - - if (isAnyOf(asyncThunk1.fulfilled, asyncThunk2.fulfilled)(action)) { - return { - prop1: action.payload.prop1, - // @ts-expect-error - prop2: action.payload.prop2, - // @ts-expect-error - prop3: action.payload.prop3, - } - } -} - -/* - * Test: isAnyOf correctly narrows types when used with type guards - */ -function isAnyOfTypeGuardTest(action: UnknownAction) { - interface ActionA { - type: 'a' - payload: { - prop1: 1 - prop3: 2 - } - } - - interface ActionB { - type: 'b' - payload: { - prop1: 1 - prop2: 2 - } - } - - const guardA = (v: any): v is ActionA => { - return v.type === 'a' - } - - const guardB = (v: any): v is ActionB => { - return v.type === 'b' - } - - if (isAnyOf(guardA, guardB)(action)) { - return { - prop1: action.payload.prop1, - // @ts-expect-error - prop2: action.payload.prop2, - // @ts-expect-error - prop3: action.payload.prop3, - } - } -} - -/* isAllOf */ - -interface SpecialAction { - payload: { - special: boolean - } -} - -const isSpecialAction = (v: any): v is SpecialAction => { - return v.meta.isSpecial -} - -/* - * Test: isAllOf correctly narrows types when used with action creators - * and type guards - */ -function isAllOfActionTest(action: UnknownAction) { - const actionA = createAction('a', () => { - return { - payload: { - prop1: 1, - prop3: 2, - }, - } - }) - - if (isAllOf(actionA, isSpecialAction)(action)) { - return { - prop1: action.payload.prop1, - // @ts-expect-error - prop2: action.payload.prop2, - prop3: action.payload.prop3, - special: action.payload.special, - } - } -} - -/* - * Test: isAllOf correctly narrows types when used with async thunks - * and type guards - */ -function isAllOfThunkTest(action: UnknownAction) { - const asyncThunk1 = createAsyncThunk<{ prop1: number; prop3: number }>( - 'asyncThunk1', - - async () => { - return { - prop1: 1, - prop3: 3, - } - } - ) - - if (isAllOf(asyncThunk1.fulfilled, isSpecialAction)(action)) { - return { - prop1: action.payload.prop1, - // @ts-expect-error - prop2: action.payload.prop2, - prop3: action.payload.prop3, - special: action.payload.special, - } - } -} - -/* - * Test: isAnyOf correctly narrows types when used with type guards - */ -function isAllOfTypeGuardTest(action: UnknownAction) { - interface ActionA { - type: 'a' - payload: { - prop1: 1 - prop3: 2 - } - } - - const guardA = (v: any): v is ActionA => { - return v.type === 'a' - } - - if (isAllOf(guardA, isSpecialAction)(action)) { - return { - prop1: action.payload.prop1, - // @ts-expect-error - prop2: action.payload.prop2, - prop3: action.payload.prop3, - special: action.payload.special, - } - } -} - -/* - * Test: isPending correctly narrows types - */ -function isPendingTest(action: UnknownAction) { - if (isPending(action)) { - expectExactType(action.payload) - // @ts-expect-error - action.error - } - - const thunk = createAsyncThunk('a', () => 'result') - - if (isPending(thunk)(action)) { - expectExactType(action.payload) - // @ts-expect-error - action.error - } -} - -/* - * Test: isRejected correctly narrows types - */ -function isRejectedTest(action: UnknownAction) { - if (isRejected(action)) { - // might be there if rejected with payload - expectUnknown(action.payload) - expectExactType(action.error) - } - - const thunk = createAsyncThunk('a', () => 'result') - - if (isRejected(thunk)(action)) { - // might be there if rejected with payload - expectUnknown(action.payload) - expectExactType(action.error) - } -} - -/* - * Test: isFulfilled correctly narrows types - */ -function isFulfilledTest(action: UnknownAction) { - if (isFulfilled(action)) { - expectUnknown(action.payload) - // @ts-expect-error - action.error - } - - const thunk = createAsyncThunk('a', () => 'result') - if (isFulfilled(thunk)(action)) { - expectExactType('' as string)(action.payload) - // @ts-expect-error - action.error - } -} - -/* - * Test: isAsyncThunkAction correctly narrows types - */ -function isAsyncThunkActionTest(action: UnknownAction) { - if (isAsyncThunkAction(action)) { - expectUnknown(action.payload) - // do not expect an error property because pending/fulfilled lack it - // @ts-expect-error - action.error - } - - const thunk = createAsyncThunk('a', () => 'result') - if (isAsyncThunkAction(thunk)(action)) { - // we should expect the payload to be available, but of unknown type because the action may be pending/rejected - expectUnknown(action.payload) - // do not expect an error property because pending/fulfilled lack it - // @ts-expect-error - action.error - } -} - -/* - * Test: isRejectedWithValue correctly narrows types - */ -function isRejectedWithValueTest(action: UnknownAction) { - if (isRejectedWithValue(action)) { - expectUnknown(action.payload) - expectExactType(action.error) - } - - const thunk = createAsyncThunk< - string, - void, - { rejectValue: { message: string } } - >('a', () => 'result') - if (isRejectedWithValue(thunk)(action)) { - expectExactType({ message: '' as string })(action.payload) - expectExactType(action.error) - } -} - -function matchersAcceptSpreadArguments() { - const thunk1 = createAsyncThunk('a', () => 'a') - const thunk2 = createAsyncThunk('b', () => 'b') - const interestingThunks = [thunk1, thunk2] - const interestingPendingThunks = interestingThunks.map( - (thunk) => thunk.pending - ) - const interestingFulfilledThunks = interestingThunks.map( - (thunk) => thunk.fulfilled - ) - - const isLoading = isAnyOf(...interestingPendingThunks) - const isNotLoading = isAnyOf(...interestingFulfilledThunks) - - const isAllLoading = isAllOf(...interestingPendingThunks) -} diff --git a/packages/toolkit/src/tests/utils/typeTestHelpers.ts b/packages/toolkit/src/tests/utils/typeTestHelpers.ts deleted file mode 100644 index 4ba7eb8b8a..0000000000 --- a/packages/toolkit/src/tests/utils/typeTestHelpers.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { IsAny, IsUnknown } from '../../tsHelpers' - -export function expectType(t: T): T { - return t -} - -type Equals = IsAny< - T, - never, - IsAny -> -export function expectExactType(t: T) { - return >(u: U) => {} -} - -type EnsureUnknown = IsUnknown -export function expectUnknown>(t: T) { - return t -} - -type EnsureAny = IsAny -export function expectExactAny>(t: T) { - return t -} - -type IsNotAny = IsAny -export function expectNotAny>(t: T): T { - return t -} - -expectType('5' as string) -expectType('5' as const) -expectType('5' as any) -expectExactType('5' as const)('5' as const) -// @ts-expect-error -expectExactType('5' as string)('5' as const) -// @ts-expect-error -expectExactType('5' as any)('5' as const) -expectUnknown('5' as unknown) -// @ts-expect-error -expectUnknown('5' as const) -// @ts-expect-error -expectUnknown('5' as any) -expectExactAny('5' as any) -// @ts-expect-error -expectExactAny('5' as const) -// @ts-expect-error -expectExactAny('5' as unknown)