import type { UnknownAction, Reducer, StateFromReducersMapObject } from 'redux' import { combineReducers } from 'redux' import { nanoid } from './nanoid' import type { Id, NonUndefined, Tail, UnionToIntersection, WithOptionalProp, } from './tsHelpers' import { emplace } from './utils' type SliceLike = { reducerPath: ReducerPath reducer: Reducer } type AnySliceLike = SliceLike type SliceLikeReducerPath = A extends SliceLike ? ReducerPath : never type SliceLikeState = A extends SliceLike ? State : never export type WithSlice = { [Path in SliceLikeReducerPath]: SliceLikeState } type ReducerMap = Record type ExistingSliceLike = { [ReducerPath in keyof DeclaredState]: SliceLike< ReducerPath & string, NonUndefined > }[keyof DeclaredState] export type InjectConfig = { /** * Allow replacing reducer with a different reference. Normally, an error will be thrown if a different reducer instance to the one already injected is used. */ overrideExisting?: boolean } /** * A reducer that allows for slices/reducers to be injected after initialisation. */ export interface CombinedSliceReducer< InitialState, DeclaredState = InitialState, > extends Reducer> { /** * Provide a type for slices that will be injected lazily. * * One way to do this would be with interface merging: * ```ts * * export interface LazyLoadedSlices {} * * export const rootReducer = combineSlices(stringSlice).withLazyLoadedSlices(); * * // elsewhere * * declare module './reducer' { * export interface LazyLoadedSlices extends WithSlice {} * } * * const withBoolean = rootReducer.inject(booleanSlice); * * // elsewhere again * * declare module './reducer' { * export interface LazyLoadedSlices { * customName: CustomState * } * } * * const withCustom = rootReducer.inject({ reducerPath: "customName", reducer: customSlice.reducer }) * ``` */ withLazyLoadedSlices(): CombinedSliceReducer< InitialState, Id> > /** * Inject a slice. * * Accepts an individual slice, RTKQ API instance, or a "slice-like" { reducerPath, reducer } object. * * ```ts * rootReducer.inject(booleanSlice) * rootReducer.inject(baseApi) * rootReducer.inject({ reducerPath: 'boolean' as const, reducer: newReducer }, { overrideExisting: true }) * ``` * */ inject>>( slice: Sl, config?: InjectConfig, ): CombinedSliceReducer>> /** * Inject a slice. * * Accepts an individual slice, RTKQ API instance, or a "slice-like" { reducerPath, reducer } object. * * ```ts * rootReducer.inject(booleanSlice) * rootReducer.inject(baseApi) * rootReducer.inject({ reducerPath: 'boolean' as const, reducer: newReducer }, { overrideExisting: true }) * ``` * */ inject( slice: SliceLike< ReducerPath, State & (ReducerPath extends keyof DeclaredState ? never : State) >, config?: InjectConfig, ): CombinedSliceReducer< InitialState, Id>> > /** * Create a selector that guarantees that the slices injected will have a defined value when selector is run. * * ```ts * const selectBooleanWithoutInjection = (state: RootState) => state.boolean; * // ^? boolean | undefined * * const selectBoolean = rootReducer.inject(booleanSlice).selector((state) => { * // if action hasn't been dispatched since slice was injected, this would usually be undefined * // however selector() uses a Proxy around the first parameter to ensure that it evaluates to the initial state instead, if undefined * return state.boolean; * // ^? boolean * }) * ``` * * If the reducer is nested inside the root state, a selectState callback can be passed to retrieve the reducer's state. * * ```ts * * export interface LazyLoadedSlices {}; * * export const innerReducer = combineSlices(stringSlice).withLazyLoadedSlices(); * * export const rootReducer = combineSlices({ inner: innerReducer }); * * export type RootState = ReturnType; * * // elsewhere * * declare module "./reducer.ts" { * export interface LazyLoadedSlices extends WithSlice {} * } * * const withBool = innerReducer.inject(booleanSlice); * * const selectBoolean = withBool.selector( * (state) => state.boolean, * (rootState: RootState) => state.inner * ); * // now expects to be passed RootState instead of innerReducer state * * ``` * * Value passed to selectorFn will be a Proxy - use selector.original(proxy) to get original state value (useful for debugging) * * ```ts * const injectedReducer = rootReducer.inject(booleanSlice); * const selectBoolean = injectedReducer.selector((state) => { * console.log(injectedReducer.selector.original(state).boolean) // possibly undefined * return state.boolean * }) * ``` */ selector: { /** * Create a selector that guarantees that the slices injected will have a defined value when selector is run. * * ```ts * const selectBooleanWithoutInjection = (state: RootState) => state.boolean; * // ^? boolean | undefined * * const selectBoolean = rootReducer.inject(booleanSlice).selector((state) => { * // if action hasn't been dispatched since slice was injected, this would usually be undefined * // however selector() uses a Proxy around the first parameter to ensure that it evaluates to the initial state instead, if undefined * return state.boolean; * // ^? boolean * }) * ``` * * Value passed to selectorFn will be a Proxy - use selector.original(proxy) to get original state value (useful for debugging) * * ```ts * const injectedReducer = rootReducer.inject(booleanSlice); * const selectBoolean = injectedReducer.selector((state) => { * console.log(injectedReducer.selector.original(state).boolean) // undefined * return state.boolean * }) * ``` */ unknown>( selectorFn: Selector, ): ( state: WithOptionalProp< Parameters[0], Exclude >, ...args: Tail> ) => ReturnType /** * Create a selector that guarantees that the slices injected will have a defined value when selector is run. * * ```ts * const selectBooleanWithoutInjection = (state: RootState) => state.boolean; * // ^? boolean | undefined * * const selectBoolean = rootReducer.inject(booleanSlice).selector((state) => { * // if action hasn't been dispatched since slice was injected, this would usually be undefined * // however selector() uses a Proxy around the first parameter to ensure that it evaluates to the initial state instead, if undefined * return state.boolean; * // ^? boolean * }) * ``` * * If the reducer is nested inside the root state, a selectState callback can be passed to retrieve the reducer's state. * * ```ts * * interface LazyLoadedSlices {}; * * const innerReducer = combineSlices(stringSlice).withLazyLoadedSlices(); * * const rootReducer = combineSlices({ inner: innerReducer }); * * type RootState = ReturnType; * * // elsewhere * * declare module "./reducer.ts" { * interface LazyLoadedSlices extends WithSlice {} * } * * const withBool = innerReducer.inject(booleanSlice); * * const selectBoolean = withBool.selector( * (state) => state.boolean, * (rootState: RootState) => state.inner * ); * // now expects to be passed RootState instead of innerReducer state * * ``` * * Value passed to selectorFn will be a Proxy - use selector.original(proxy) to get original state value (useful for debugging) * * ```ts * const injectedReducer = rootReducer.inject(booleanSlice); * const selectBoolean = injectedReducer.selector((state) => { * console.log(injectedReducer.selector.original(state).boolean) // possibly undefined * return state.boolean * }) * ``` */ < Selector extends (state: DeclaredState, ...args: any[]) => unknown, RootState, >( selectorFn: Selector, selectState: ( rootState: RootState, ...args: Tail> ) => WithOptionalProp< Parameters[0], Exclude >, ): ( state: RootState, ...args: Tail> ) => ReturnType /** * Returns the unproxied state. Useful for debugging. * @param state state Proxy, that ensures injected reducers have value * @returns original, unproxied state * @throws if value passed is not a state Proxy */ original: (state: DeclaredState) => InitialState & Partial } } type InitialState> = UnionToIntersection< Slices[number] extends infer Slice ? Slice extends AnySliceLike ? WithSlice : StateFromReducersMapObject : never > const isSliceLike = ( maybeSliceLike: AnySliceLike | ReducerMap, ): maybeSliceLike is AnySliceLike => 'reducerPath' in maybeSliceLike && typeof maybeSliceLike.reducerPath === 'string' const getReducers = (slices: Array) => slices.flatMap((sliceOrMap) => isSliceLike(sliceOrMap) ? [[sliceOrMap.reducerPath, sliceOrMap.reducer] as const] : Object.entries(sliceOrMap), ) const ORIGINAL_STATE = Symbol.for('rtk-state-proxy-original') const isStateProxy = (value: any) => !!value && !!value[ORIGINAL_STATE] const stateProxyMap = new WeakMap() const createStateProxy = ( state: State, reducerMap: Partial>, ) => emplace(stateProxyMap, state, { insert: () => new Proxy(state, { get: (target, prop, receiver) => { if (prop === ORIGINAL_STATE) return target const result = Reflect.get(target, prop, receiver) if (typeof result === 'undefined') { const reducer = reducerMap[prop.toString()] if (reducer) { // ensure action type is random, to prevent reducer treating it differently const reducerResult = reducer(undefined, { type: nanoid() }) if (typeof reducerResult === 'undefined') { throw new Error( `The slice reducer for key "${prop.toString()}" returned undefined when called for selector(). ` + `If the state passed to the reducer is undefined, you must ` + `explicitly return the initial state. The initial state may ` + `not be undefined. If you don't want to set a value for this reducer, ` + `you can use null instead of undefined.`, ) } return reducerResult } } return result }, }), }) as State const original = (state: any) => { if (!isStateProxy(state)) { throw new Error('original must be used on state Proxy') } return state[ORIGINAL_STATE] } const noopReducer: Reducer> = (state = {}) => state export function combineSlices>( ...slices: Slices ): CombinedSliceReducer>> { const reducerMap = Object.fromEntries(getReducers(slices)) const getReducer = () => Object.keys(reducerMap).length ? combineReducers(reducerMap) : noopReducer let reducer = getReducer() function combinedReducer( state: Record, action: UnknownAction, ) { return reducer(state, action) } combinedReducer.withLazyLoadedSlices = () => combinedReducer const inject = ( slice: AnySliceLike, config: InjectConfig = {}, ): typeof combinedReducer => { const { reducerPath, reducer: reducerToInject } = slice const currentReducer = reducerMap[reducerPath] if ( !config.overrideExisting && currentReducer && currentReducer !== reducerToInject ) { if ( typeof process !== 'undefined' && process.env.NODE_ENV === 'development' ) { console.error( `called \`inject\` to override already-existing reducer ${reducerPath} without specifying \`overrideExisting: true\``, ) } return combinedReducer } reducerMap[reducerPath] = reducerToInject reducer = getReducer() return combinedReducer } const selector = Object.assign( function makeSelector( selectorFn: (state: State, ...args: Args) => any, selectState?: (rootState: RootState, ...args: Args) => State, ) { return function selector(state: State, ...args: Args) { return selectorFn( createStateProxy( selectState ? selectState(state as any, ...args) : state, reducerMap, ), ...args, ) } }, { original }, ) return Object.assign(combinedReducer, { inject, selector }) as any }