UNPKG

14.8 kBPlain TextView Raw
1import type { UnknownAction, Reducer, StateFromReducersMapObject } from 'redux'
2import { combineReducers } from 'redux'
3import { nanoid } from './nanoid'
4import type {
5 Id,
6 NonUndefined,
7 Tail,
8 UnionToIntersection,
9 WithOptionalProp,
10} from './tsHelpers'
11import { emplace } from './utils'
12
13type SliceLike<ReducerPath extends string, State> = {
14 reducerPath: ReducerPath
15 reducer: Reducer<State>
16}
17
18type AnySliceLike = SliceLike<string, any>
19
20type SliceLikeReducerPath<A extends AnySliceLike> =
21 A extends SliceLike<infer ReducerPath, any> ? ReducerPath : never
22
23type SliceLikeState<A extends AnySliceLike> =
24 A extends SliceLike<any, infer State> ? State : never
25
26export type WithSlice<A extends AnySliceLike> = {
27 [Path in SliceLikeReducerPath<A>]: SliceLikeState<A>
28}
29
30type ReducerMap = Record<string, Reducer>
31
32type ExistingSliceLike<DeclaredState> = {
33 [ReducerPath in keyof DeclaredState]: SliceLike<
34 ReducerPath & string,
35 NonUndefined<DeclaredState[ReducerPath]>
36 >
37}[keyof DeclaredState]
38
39export type InjectConfig = {
40 /**
41 * 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.
42 */
43 overrideExisting?: boolean
44}
45
46/**
47 * A reducer that allows for slices/reducers to be injected after initialisation.
48 */
49export interface CombinedSliceReducer<
50 InitialState,
51 DeclaredState = InitialState,
52> extends Reducer<DeclaredState, UnknownAction, Partial<DeclaredState>> {
53 /**
54 * Provide a type for slices that will be injected lazily.
55 *
56 * One way to do this would be with interface merging:
57 * ```ts
58 *
59 * export interface LazyLoadedSlices {}
60 *
61 * export const rootReducer = combineSlices(stringSlice).withLazyLoadedSlices<LazyLoadedSlices>();
62 *
63 * // elsewhere
64 *
65 * declare module './reducer' {
66 * export interface LazyLoadedSlices extends WithSlice<typeof booleanSlice> {}
67 * }
68 *
69 * const withBoolean = rootReducer.inject(booleanSlice);
70 *
71 * // elsewhere again
72 *
73 * declare module './reducer' {
74 * export interface LazyLoadedSlices {
75 * customName: CustomState
76 * }
77 * }
78 *
79 * const withCustom = rootReducer.inject({ reducerPath: "customName", reducer: customSlice.reducer })
80 * ```
81 */
82 withLazyLoadedSlices<Lazy = {}>(): CombinedSliceReducer<
83 InitialState,
84 Id<DeclaredState & Partial<Lazy>>
85 >
86
87 /**
88 * Inject a slice.
89 *
90 * Accepts an individual slice, RTKQ API instance, or a "slice-like" { reducerPath, reducer } object.
91 *
92 * ```ts
93 * rootReducer.inject(booleanSlice)
94 * rootReducer.inject(baseApi)
95 * rootReducer.inject({ reducerPath: 'boolean' as const, reducer: newReducer }, { overrideExisting: true })
96 * ```
97 *
98 */
99 inject<Sl extends Id<ExistingSliceLike<DeclaredState>>>(
100 slice: Sl,
101 config?: InjectConfig,
102 ): CombinedSliceReducer<InitialState, Id<DeclaredState & WithSlice<Sl>>>
103
104 /**
105 * Inject a slice.
106 *
107 * Accepts an individual slice, RTKQ API instance, or a "slice-like" { reducerPath, reducer } object.
108 *
109 * ```ts
110 * rootReducer.inject(booleanSlice)
111 * rootReducer.inject(baseApi)
112 * rootReducer.inject({ reducerPath: 'boolean' as const, reducer: newReducer }, { overrideExisting: true })
113 * ```
114 *
115 */
116 inject<ReducerPath extends string, State>(
117 slice: SliceLike<
118 ReducerPath,
119 State & (ReducerPath extends keyof DeclaredState ? never : State)
120 >,
121 config?: InjectConfig,
122 ): CombinedSliceReducer<
123 InitialState,
124 Id<DeclaredState & WithSlice<SliceLike<ReducerPath, State>>>
125 >
126
127 /**
128 * Create a selector that guarantees that the slices injected will have a defined value when selector is run.
129 *
130 * ```ts
131 * const selectBooleanWithoutInjection = (state: RootState) => state.boolean;
132 * // ^? boolean | undefined
133 *
134 * const selectBoolean = rootReducer.inject(booleanSlice).selector((state) => {
135 * // if action hasn't been dispatched since slice was injected, this would usually be undefined
136 * // however selector() uses a Proxy around the first parameter to ensure that it evaluates to the initial state instead, if undefined
137 * return state.boolean;
138 * // ^? boolean
139 * })
140 * ```
141 *
142 * If the reducer is nested inside the root state, a selectState callback can be passed to retrieve the reducer's state.
143 *
144 * ```ts
145 *
146 * export interface LazyLoadedSlices {};
147 *
148 * export const innerReducer = combineSlices(stringSlice).withLazyLoadedSlices<LazyLoadedSlices>();
149 *
150 * export const rootReducer = combineSlices({ inner: innerReducer });
151 *
152 * export type RootState = ReturnType<typeof rootReducer>;
153 *
154 * // elsewhere
155 *
156 * declare module "./reducer.ts" {
157 * export interface LazyLoadedSlices extends WithSlice<typeof booleanSlice> {}
158 * }
159 *
160 * const withBool = innerReducer.inject(booleanSlice);
161 *
162 * const selectBoolean = withBool.selector(
163 * (state) => state.boolean,
164 * (rootState: RootState) => state.inner
165 * );
166 * // now expects to be passed RootState instead of innerReducer state
167 *
168 * ```
169 *
170 * Value passed to selectorFn will be a Proxy - use selector.original(proxy) to get original state value (useful for debugging)
171 *
172 * ```ts
173 * const injectedReducer = rootReducer.inject(booleanSlice);
174 * const selectBoolean = injectedReducer.selector((state) => {
175 * console.log(injectedReducer.selector.original(state).boolean) // possibly undefined
176 * return state.boolean
177 * })
178 * ```
179 */
180 selector: {
181 /**
182 * Create a selector that guarantees that the slices injected will have a defined value when selector is run.
183 *
184 * ```ts
185 * const selectBooleanWithoutInjection = (state: RootState) => state.boolean;
186 * // ^? boolean | undefined
187 *
188 * const selectBoolean = rootReducer.inject(booleanSlice).selector((state) => {
189 * // if action hasn't been dispatched since slice was injected, this would usually be undefined
190 * // however selector() uses a Proxy around the first parameter to ensure that it evaluates to the initial state instead, if undefined
191 * return state.boolean;
192 * // ^? boolean
193 * })
194 * ```
195 *
196 * Value passed to selectorFn will be a Proxy - use selector.original(proxy) to get original state value (useful for debugging)
197 *
198 * ```ts
199 * const injectedReducer = rootReducer.inject(booleanSlice);
200 * const selectBoolean = injectedReducer.selector((state) => {
201 * console.log(injectedReducer.selector.original(state).boolean) // undefined
202 * return state.boolean
203 * })
204 * ```
205 */
206 <Selector extends (state: DeclaredState, ...args: any[]) => unknown>(
207 selectorFn: Selector,
208 ): (
209 state: WithOptionalProp<
210 Parameters<Selector>[0],
211 Exclude<keyof DeclaredState, keyof InitialState>
212 >,
213 ...args: Tail<Parameters<Selector>>
214 ) => ReturnType<Selector>
215
216 /**
217 * Create a selector that guarantees that the slices injected will have a defined value when selector is run.
218 *
219 * ```ts
220 * const selectBooleanWithoutInjection = (state: RootState) => state.boolean;
221 * // ^? boolean | undefined
222 *
223 * const selectBoolean = rootReducer.inject(booleanSlice).selector((state) => {
224 * // if action hasn't been dispatched since slice was injected, this would usually be undefined
225 * // however selector() uses a Proxy around the first parameter to ensure that it evaluates to the initial state instead, if undefined
226 * return state.boolean;
227 * // ^? boolean
228 * })
229 * ```
230 *
231 * If the reducer is nested inside the root state, a selectState callback can be passed to retrieve the reducer's state.
232 *
233 * ```ts
234 *
235 * interface LazyLoadedSlices {};
236 *
237 * const innerReducer = combineSlices(stringSlice).withLazyLoadedSlices<LazyLoadedSlices>();
238 *
239 * const rootReducer = combineSlices({ inner: innerReducer });
240 *
241 * type RootState = ReturnType<typeof rootReducer>;
242 *
243 * // elsewhere
244 *
245 * declare module "./reducer.ts" {
246 * interface LazyLoadedSlices extends WithSlice<typeof booleanSlice> {}
247 * }
248 *
249 * const withBool = innerReducer.inject(booleanSlice);
250 *
251 * const selectBoolean = withBool.selector(
252 * (state) => state.boolean,
253 * (rootState: RootState) => state.inner
254 * );
255 * // now expects to be passed RootState instead of innerReducer state
256 *
257 * ```
258 *
259 * Value passed to selectorFn will be a Proxy - use selector.original(proxy) to get original state value (useful for debugging)
260 *
261 * ```ts
262 * const injectedReducer = rootReducer.inject(booleanSlice);
263 * const selectBoolean = injectedReducer.selector((state) => {
264 * console.log(injectedReducer.selector.original(state).boolean) // possibly undefined
265 * return state.boolean
266 * })
267 * ```
268 */
269 <
270 Selector extends (state: DeclaredState, ...args: any[]) => unknown,
271 RootState,
272 >(
273 selectorFn: Selector,
274 selectState: (
275 rootState: RootState,
276 ...args: Tail<Parameters<Selector>>
277 ) => WithOptionalProp<
278 Parameters<Selector>[0],
279 Exclude<keyof DeclaredState, keyof InitialState>
280 >,
281 ): (
282 state: RootState,
283 ...args: Tail<Parameters<Selector>>
284 ) => ReturnType<Selector>
285 /**
286 * Returns the unproxied state. Useful for debugging.
287 * @param state state Proxy, that ensures injected reducers have value
288 * @returns original, unproxied state
289 * @throws if value passed is not a state Proxy
290 */
291 original: (state: DeclaredState) => InitialState & Partial<DeclaredState>
292 }
293}
294
295type InitialState<Slices extends Array<AnySliceLike | ReducerMap>> =
296 UnionToIntersection<
297 Slices[number] extends infer Slice
298 ? Slice extends AnySliceLike
299 ? WithSlice<Slice>
300 : StateFromReducersMapObject<Slice>
301 : never
302 >
303
304const isSliceLike = (
305 maybeSliceLike: AnySliceLike | ReducerMap,
306): maybeSliceLike is AnySliceLike =>
307 'reducerPath' in maybeSliceLike &&
308 typeof maybeSliceLike.reducerPath === 'string'
309
310const getReducers = (slices: Array<AnySliceLike | ReducerMap>) =>
311 slices.flatMap((sliceOrMap) =>
312 isSliceLike(sliceOrMap)
313 ? [[sliceOrMap.reducerPath, sliceOrMap.reducer] as const]
314 : Object.entries(sliceOrMap),
315 )
316
317const ORIGINAL_STATE = Symbol.for('rtk-state-proxy-original')
318
319const isStateProxy = (value: any) => !!value && !!value[ORIGINAL_STATE]
320
321const stateProxyMap = new WeakMap<object, object>()
322
323const createStateProxy = <State extends object>(
324 state: State,
325 reducerMap: Partial<Record<string, Reducer>>,
326) =>
327 emplace(stateProxyMap, state, {
328 insert: () =>
329 new Proxy(state, {
330 get: (target, prop, receiver) => {
331 if (prop === ORIGINAL_STATE) return target
332 const result = Reflect.get(target, prop, receiver)
333 if (typeof result === 'undefined') {
334 const reducer = reducerMap[prop.toString()]
335 if (reducer) {
336 // ensure action type is random, to prevent reducer treating it differently
337 const reducerResult = reducer(undefined, { type: nanoid() })
338 if (typeof reducerResult === 'undefined') {
339 throw new Error(
340 `The slice reducer for key "${prop.toString()}" returned undefined when called for selector(). ` +
341 `If the state passed to the reducer is undefined, you must ` +
342 `explicitly return the initial state. The initial state may ` +
343 `not be undefined. If you don't want to set a value for this reducer, ` +
344 `you can use null instead of undefined.`,
345 )
346 }
347 return reducerResult
348 }
349 }
350 return result
351 },
352 }),
353 }) as State
354
355const original = (state: any) => {
356 if (!isStateProxy(state)) {
357 throw new Error('original must be used on state Proxy')
358 }
359 return state[ORIGINAL_STATE]
360}
361
362const noopReducer: Reducer<Record<string, any>> = (state = {}) => state
363
364export function combineSlices<Slices extends Array<AnySliceLike | ReducerMap>>(
365 ...slices: Slices
366): CombinedSliceReducer<Id<InitialState<Slices>>> {
367 const reducerMap = Object.fromEntries<Reducer>(getReducers(slices))
368
369 const getReducer = () =>
370 Object.keys(reducerMap).length ? combineReducers(reducerMap) : noopReducer
371
372 let reducer = getReducer()
373
374 function combinedReducer(
375 state: Record<string, unknown>,
376 action: UnknownAction,
377 ) {
378 return reducer(state, action)
379 }
380
381 combinedReducer.withLazyLoadedSlices = () => combinedReducer
382
383 const inject = (
384 slice: AnySliceLike,
385 config: InjectConfig = {},
386 ): typeof combinedReducer => {
387 const { reducerPath, reducer: reducerToInject } = slice
388
389 const currentReducer = reducerMap[reducerPath]
390 if (
391 !config.overrideExisting &&
392 currentReducer &&
393 currentReducer !== reducerToInject
394 ) {
395 if (
396 typeof process !== 'undefined' &&
397 process.env.NODE_ENV === 'development'
398 ) {
399 console.error(
400 `called \`inject\` to override already-existing reducer ${reducerPath} without specifying \`overrideExisting: true\``,
401 )
402 }
403
404 return combinedReducer
405 }
406
407 reducerMap[reducerPath] = reducerToInject
408
409 reducer = getReducer()
410
411 return combinedReducer
412 }
413
414 const selector = Object.assign(
415 function makeSelector<State extends object, RootState, Args extends any[]>(
416 selectorFn: (state: State, ...args: Args) => any,
417 selectState?: (rootState: RootState, ...args: Args) => State,
418 ) {
419 return function selector(state: State, ...args: Args) {
420 return selectorFn(
421 createStateProxy(
422 selectState ? selectState(state as any, ...args) : state,
423 reducerMap,
424 ),
425 ...args,
426 )
427 }
428 },
429 { original },
430 )
431
432 return Object.assign(combinedReducer, { inject, selector }) as any
433}