1 | import type { UnknownAction, Reducer, StateFromReducersMapObject } from 'redux'
|
2 | import { combineReducers } from 'redux'
|
3 | import { nanoid } from './nanoid'
|
4 | import type {
|
5 | Id,
|
6 | NonUndefined,
|
7 | Tail,
|
8 | UnionToIntersection,
|
9 | WithOptionalProp,
|
10 | } from './tsHelpers'
|
11 | import { emplace } from './utils'
|
12 |
|
13 | type SliceLike<ReducerPath extends string, State> = {
|
14 | reducerPath: ReducerPath
|
15 | reducer: Reducer<State>
|
16 | }
|
17 |
|
18 | type AnySliceLike = SliceLike<string, any>
|
19 |
|
20 | type SliceLikeReducerPath<A extends AnySliceLike> =
|
21 | A extends SliceLike<infer ReducerPath, any> ? ReducerPath : never
|
22 |
|
23 | type SliceLikeState<A extends AnySliceLike> =
|
24 | A extends SliceLike<any, infer State> ? State : never
|
25 |
|
26 | export type WithSlice<A extends AnySliceLike> = {
|
27 | [Path in SliceLikeReducerPath<A>]: SliceLikeState<A>
|
28 | }
|
29 |
|
30 | type ReducerMap = Record<string, Reducer>
|
31 |
|
32 | type ExistingSliceLike<DeclaredState> = {
|
33 | [ReducerPath in keyof DeclaredState]: SliceLike<
|
34 | ReducerPath & string,
|
35 | NonUndefined<DeclaredState[ReducerPath]>
|
36 | >
|
37 | }[keyof DeclaredState]
|
38 |
|
39 | export 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 | */
|
49 | export 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 |
|
295 | type 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 |
|
304 | const isSliceLike = (
|
305 | maybeSliceLike: AnySliceLike | ReducerMap,
|
306 | ): maybeSliceLike is AnySliceLike =>
|
307 | 'reducerPath' in maybeSliceLike &&
|
308 | typeof maybeSliceLike.reducerPath === 'string'
|
309 |
|
310 | const 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 |
|
317 | const ORIGINAL_STATE = Symbol.for('rtk-state-proxy-original')
|
318 |
|
319 | const isStateProxy = (value: any) => !!value && !!value[ORIGINAL_STATE]
|
320 |
|
321 | const stateProxyMap = new WeakMap<object, object>()
|
322 |
|
323 | const 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 |
|
355 | const 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 |
|
362 | const noopReducer: Reducer<Record<string, any>> = (state = {}) => state
|
363 |
|
364 | export 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 | }
|