diff --git a/packages/toolkit/src/combineSlices.ts b/packages/toolkit/src/combineSlices.ts index aedfdac70e..b7bec4a0aa 100644 --- a/packages/toolkit/src/combineSlices.ts +++ b/packages/toolkit/src/combineSlices.ts @@ -1,4 +1,9 @@ -import type { Reducer, StateFromReducersMapObject, UnknownAction } from 'redux' +import type { + PreloadedStateShapeFromReducersMapObject, + Reducer, + StateFromReducersMapObject, + UnknownAction, +} from 'redux' import { combineReducers } from 'redux' import { nanoid } from './nanoid' import type { @@ -10,9 +15,9 @@ import type { } from './tsHelpers' import { getOrInsertComputed } from './utils' -type SliceLike = { +type SliceLike = { reducerPath: ReducerPath - reducer: Reducer + reducer: Reducer } type AnySliceLike = SliceLike @@ -21,18 +26,26 @@ type SliceLikeReducerPath = A extends SliceLike ? ReducerPath : never type SliceLikeState = - A extends SliceLike ? State : never + A extends SliceLike ? State : never + +type SliceLikePreloadedState = + A extends SliceLike ? PreloadedState : never export type WithSlice = { [Path in SliceLikeReducerPath]: SliceLikeState } +export type WithSlicePreloadedState = { + [Path in SliceLikeReducerPath]: SliceLikePreloadedState +} + type ReducerMap = Record -type ExistingSliceLike = { +type ExistingSliceLike = { [ReducerPath in keyof DeclaredState]: SliceLike< ReducerPath & string, - NonUndefined + NonUndefined, + NonUndefined > }[keyof DeclaredState] @@ -48,8 +61,11 @@ export type InjectConfig = { */ export interface CombinedSliceReducer< InitialState, - DeclaredState = InitialState, -> extends Reducer> { + DeclaredState extends InitialState = InitialState, + PreloadedState extends Partial< + Record + > = Partial, +> extends Reducer { /** * Provide a type for slices that will be injected lazily. * @@ -79,9 +95,10 @@ export interface CombinedSliceReducer< * const withCustom = rootReducer.inject({ reducerPath: "customName", reducer: customSlice.reducer }) * ``` */ - withLazyLoadedSlices(): CombinedSliceReducer< + withLazyLoadedSlices(): CombinedSliceReducer< InitialState, - Id> + Id>, + Id> > /** @@ -96,10 +113,14 @@ export interface CombinedSliceReducer< * ``` * */ - inject>>( + inject>>( slice: Sl, config?: InjectConfig, - ): CombinedSliceReducer>> + ): CombinedSliceReducer< + InitialState, + Id>, + Id>> + > /** * Inject a slice. @@ -113,15 +134,21 @@ export interface CombinedSliceReducer< * ``` * */ - inject( + inject( slice: SliceLike< ReducerPath, - State & (ReducerPath extends keyof DeclaredState ? never : State) + State & (ReducerPath extends keyof DeclaredState ? never : State), + PreloadedState & + (ReducerPath extends keyof PreloadedState ? never : PreloadedState) >, config?: InjectConfig, ): CombinedSliceReducer< InitialState, - Id>> + Id>>, + Id< + PreloadedState & + WithSlicePreloadedState> + > > /** @@ -301,6 +328,15 @@ type InitialState> = : never > +type InitialPreloadedState> = + UnionToIntersection< + Slices[number] extends infer Slice + ? Slice extends AnySliceLike + ? WithSlicePreloadedState + : PreloadedStateShapeFromReducersMapObject + : never + > + const isSliceLike = ( maybeSliceLike: AnySliceLike | ReducerMap, ): maybeSliceLike is AnySliceLike => @@ -308,9 +344,9 @@ const isSliceLike = ( typeof maybeSliceLike.reducerPath === 'string' const getReducers = (slices: Array) => - slices.flatMap((sliceOrMap) => + slices.flatMap<[string, Reducer]>((sliceOrMap) => isSliceLike(sliceOrMap) - ? [[sliceOrMap.reducerPath, sliceOrMap.reducer] as const] + ? [[sliceOrMap.reducerPath, sliceOrMap.reducer]] : Object.entries(sliceOrMap), ) @@ -370,8 +406,12 @@ const noopReducer: Reducer> = (state = emptyObject) => state export function combineSlices>( ...slices: Slices -): CombinedSliceReducer>> { - const reducerMap = Object.fromEntries(getReducers(slices)) +): CombinedSliceReducer< + Id>, + Id>, + Partial>> +> { + const reducerMap = Object.fromEntries(getReducers(slices)) const getReducer = () => Object.keys(reducerMap).length ? combineReducers(reducerMap) : noopReducer diff --git a/packages/toolkit/src/index.ts b/packages/toolkit/src/index.ts index f6ada698d6..7e2b2a012c 100644 --- a/packages/toolkit/src/index.ts +++ b/packages/toolkit/src/index.ts @@ -208,7 +208,11 @@ export type { AutoBatchOptions } from './autoBatchEnhancer' export { combineSlices } from './combineSlices' -export type { CombinedSliceReducer, WithSlice } from './combineSlices' +export type { + CombinedSliceReducer, + WithSlice, + WithSlicePreloadedState, +} from './combineSlices' export type { ExtractDispatchExtensions as TSHelpersExtractDispatchExtensions, diff --git a/packages/toolkit/src/tests/combineSlices.test-d.ts b/packages/toolkit/src/tests/combineSlices.test-d.ts index e06fe53c56..5d25e00977 100644 --- a/packages/toolkit/src/tests/combineSlices.test-d.ts +++ b/packages/toolkit/src/tests/combineSlices.test-d.ts @@ -1,4 +1,10 @@ -import type { Reducer, Slice, WithSlice } from '@reduxjs/toolkit' +import type { + Action, + Reducer, + Slice, + WithSlice, + WithSlicePreloadedState, +} from '@reduxjs/toolkit' import { combineSlices } from '@reduxjs/toolkit' import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query' @@ -8,6 +14,13 @@ declare const numberSlice: Slice declare const booleanReducer: Reducer +declare const mixedReducer: Reducer + +declare const mixedSliceLike: { + reducerPath: 'mixedSlice' + reducer: typeof mixedReducer +} + const exampleApi = createApi({ baseQuery: fetchBaseQuery(), endpoints: (build) => ({ @@ -21,16 +34,31 @@ type ExampleApiState = ReturnType describe('type tests', () => { test('combineSlices correctly combines static state', () => { - const rootReducer = combineSlices(stringSlice, numberSlice, exampleApi, { - boolean: booleanReducer, - }) + const rootReducer = combineSlices( + stringSlice, + numberSlice, + exampleApi, + { + boolean: booleanReducer, + mixed: mixedReducer, + }, + mixedSliceLike, + ) expectTypeOf(rootReducer(undefined, { type: '' })).toEqualTypeOf<{ string: string number: number boolean: boolean api: ExampleApiState + mixed: string + mixedSlice: string }>() + + // test for correct preloaded state handling + expectTypeOf(rootReducer).toBeCallableWith( + { mixed: 9, mixedSlice: 9 }, + { type: '' }, + ) }) test('combineSlices allows passing no initial reducers', () => { @@ -63,7 +91,21 @@ describe('type tests', () => { test('inject marks injected keys as required', () => { const rootReducer = combineSlices(stringSlice).withLazyLoadedSlices< WithSlice & - WithSlice & { boolean: boolean } + WithSlice & { boolean: boolean } & WithSlice< + typeof mixedSliceLike + > & + WithSlice<{ + reducerPath: 'mixedReducer' + reducer: typeof mixedReducer + }>, + WithSlicePreloadedState & + WithSlicePreloadedState & { + boolean: boolean + } & WithSlicePreloadedState & + WithSlicePreloadedState<{ + reducerPath: 'mixedReducer' + reducer: typeof mixedReducer + }> >() expectTypeOf(rootReducer(undefined, { type: '' }).number).toEqualTypeOf< @@ -78,6 +120,14 @@ describe('type tests', () => { ExampleApiState | undefined >() + expectTypeOf(rootReducer(undefined, { type: '' }).mixedSlice).toEqualTypeOf< + string | undefined + >() + + expectTypeOf( + rootReducer(undefined, { type: '' }).mixedReducer, + ).toEqualTypeOf() + const withNumber = rootReducer.inject(numberSlice) expectTypeOf(withNumber(undefined, { type: '' }).number).toBeNumber() @@ -94,6 +144,21 @@ describe('type tests', () => { expectTypeOf( withApi(undefined, { type: '' }).api, ).toEqualTypeOf() + + const withMixedSlice = rootReducer.inject(mixedSliceLike) + + expectTypeOf( + withMixedSlice(undefined, { type: '' }).mixedSlice, + ).toBeString() + + const withMixedReducer = rootReducer.inject({ + reducerPath: 'mixedReducer', + reducer: mixedReducer, + }) + + expectTypeOf( + withMixedReducer(undefined, { type: '' }).mixedReducer, + ).toBeString() }) test('selector() allows defining selectors with injected reducers defined', () => {