Skip to content

Commit bccf47f

Browse files
authored
Merge pull request #1 from EskiMojo14/combine-slices-integrated
Begin investigating "integrated" approach
2 parents 1a218cf + 34bf78b commit bccf47f

File tree

4 files changed

+256
-9
lines changed

4 files changed

+256
-9
lines changed

packages/toolkit/src/combineSlices.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ type InjectConfig = {
8989
/**
9090
* A reducer that allows for slices/reducers to be injected after initialisation.
9191
*/
92-
interface CombinedSliceReducer<
92+
export interface CombinedSliceReducer<
9393
InitialState,
9494
DeclaredState = InitialState
9595
> extends Reducer<

packages/toolkit/src/createSlice.ts

+133-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { AnyAction, Reducer } from 'redux'
2-
import { createNextState } from '.'
32
import type {
43
ActionCreatorWithoutPayload,
54
PayloadAction,
@@ -16,8 +15,9 @@ import type {
1615
import { createReducer, NotFunction } from './createReducer'
1716
import type { ActionReducerMapBuilder } from './mapBuilders'
1817
import { executeReducerBuilderCallback } from './mapBuilders'
19-
import type { NoInfer } from './tsHelpers'
18+
import type { Id, NoInfer, Tail } from './tsHelpers'
2019
import { freezeDraftable } from './utils'
20+
import type { CombinedSliceReducer } from './combineSlices'
2121

2222
let hasWarnedAboutObjectNotation = false
2323

@@ -38,7 +38,8 @@ export type SliceActionCreator<P> = PayloadActionCreator<P>
3838
export interface Slice<
3939
State = any,
4040
CaseReducers extends SliceCaseReducers<State> = SliceCaseReducers<State>,
41-
Name extends string = string
41+
Name extends string = string,
42+
Selectors extends SliceSelectors<State> = {}
4243
> {
4344
/**
4445
* The slice name.
@@ -67,6 +68,49 @@ export interface Slice<
6768
* If a lazy state initializer was provided, it will be called and a fresh value returned.
6869
*/
6970
getInitialState: () => State
71+
72+
getSelectors(): Id<SliceDefinedSelectors<State, Selectors, State>>
73+
74+
getSelectors<RootState>(
75+
selectState: (rootState: RootState) => State
76+
): Id<SliceDefinedSelectors<State, Selectors, RootState>>
77+
78+
selectors: Id<SliceDefinedSelectors<State, Selectors, { [K in Name]: State }>>
79+
80+
injectInto(
81+
combinedReducer: CombinedSliceReducer<any>
82+
): InjectedSlice<State, CaseReducers, Name, Selectors>
83+
}
84+
85+
interface InjectedSlice<
86+
State = any,
87+
CaseReducers extends SliceCaseReducers<State> = SliceCaseReducers<State>,
88+
Name extends string = string,
89+
Selectors extends SliceSelectors<State> = {}
90+
> extends Omit<
91+
Slice<State, CaseReducers, Name, Selectors>,
92+
'getSelectors' | 'selectors'
93+
> {
94+
getSelectors(): Id<SliceDefinedSelectors<State, Selectors, State | undefined>>
95+
96+
getSelectors<RootState>(
97+
selectState: (rootState: RootState) => State | undefined
98+
): Id<SliceDefinedSelectors<State, Selectors, RootState>>
99+
100+
selectors: Id<
101+
SliceDefinedSelectors<State, Selectors, { [K in Name]?: State | undefined }>
102+
>
103+
}
104+
105+
type SliceDefinedSelectors<
106+
State,
107+
Selectors extends SliceSelectors<State>,
108+
RootState
109+
> = {
110+
[K in keyof Selectors as [string] extends [K] ? never : K]: (
111+
rootState: RootState,
112+
...args: Tail<Parameters<Selectors[K]>>
113+
) => ReturnType<Selectors[K]>
70114
}
71115

72116
/**
@@ -77,7 +121,8 @@ export interface Slice<
77121
export interface CreateSliceOptions<
78122
State = any,
79123
CR extends SliceCaseReducers<State> = SliceCaseReducers<State>,
80-
Name extends string = string
124+
Name extends string = string,
125+
Selectors extends SliceSelectors<State> = Record<never, never>
81126
> {
82127
/**
83128
* The slice's name. Used to namespace the generated action types.
@@ -142,6 +187,8 @@ createSlice({
142187
```
143188
*/
144189
extraReducers?: (builder: ActionReducerMapBuilder<NoInfer<State>>) => void
190+
191+
selectors?: Selectors
145192
}
146193

147194
/**
@@ -165,6 +212,10 @@ export type SliceCaseReducers<State> = {
165212
| CaseReducerWithPrepare<State, PayloadAction<any, string, any, any>>
166213
}
167214

215+
export type SliceSelectors<State> = {
216+
[K: string]: (sliceState: State, ...args: any[]) => any
217+
}
218+
168219
type SliceActionType<
169220
SliceName extends string,
170221
ActionName extends keyof any
@@ -271,10 +322,11 @@ function getType(slice: string, actionKey: string): string {
271322
export function createSlice<
272323
State,
273324
CaseReducers extends SliceCaseReducers<State>,
274-
Name extends string = string
325+
Name extends string,
326+
Selectors extends SliceSelectors<State>
275327
>(
276-
options: CreateSliceOptions<State, CaseReducers, Name>
277-
): Slice<State, CaseReducers, Name> {
328+
options: CreateSliceOptions<State, CaseReducers, Name, Selectors>
329+
): Slice<State, CaseReducers, Name, Selectors> {
278330
const { name } = options
279331
if (!name) {
280332
throw new Error('`name` is a required option for createSlice')
@@ -357,6 +409,24 @@ export function createSlice<
357409
})
358410
}
359411

412+
const defaultSelectSlice = (rootState: { [K in Name]: State }) =>
413+
rootState[name]
414+
415+
const selectSelf = (state: State) => state
416+
417+
const selectorCache = new WeakMap<
418+
(rootState: any) => State,
419+
Record<string, (rootState: any) => any>
420+
>()
421+
422+
const injectedSelectorCache = new WeakMap<
423+
CombinedSliceReducer<any>,
424+
WeakMap<
425+
(rootState: any) => State | undefined,
426+
Record<string, (rootState: any) => any>
427+
>
428+
>()
429+
360430
let _reducer: ReducerWithInitialState<State>
361431

362432
return {
@@ -373,5 +443,61 @@ export function createSlice<
373443

374444
return _reducer.getInitialState()
375445
},
446+
getSelectors(selectState?: (rootState: any) => State) {
447+
if (selectState) {
448+
const cached = selectorCache.get(selectState)
449+
if (cached) {
450+
return cached
451+
}
452+
const selectors: Record<string, (rootState: any) => any> = {}
453+
for (const [name, selector] of Object.entries(
454+
options.selectors ?? {}
455+
)) {
456+
selectors[name] = (rootState: any, ...args: any[]) =>
457+
selector(selectState(rootState), ...args)
458+
}
459+
selectorCache.set(selectState, selectors)
460+
return selectors as any
461+
} else {
462+
return options.selectors ?? {}
463+
}
464+
},
465+
get selectors() {
466+
return this.getSelectors(defaultSelectSlice)
467+
},
468+
injectInto(reducer) {
469+
reducer.inject(this)
470+
let selectorCache = injectedSelectorCache.get(reducer)
471+
if (!selectorCache) {
472+
selectorCache = new WeakMap()
473+
injectedSelectorCache.set(reducer, selectorCache)
474+
}
475+
return {
476+
...this,
477+
getSelectors(
478+
selectState: (rootState: any) => State | undefined = selectSelf
479+
) {
480+
const cached = selectorCache!.get(selectState)
481+
if (cached) {
482+
return cached
483+
}
484+
const selectors: Record<string, (rootState: any) => any> = {}
485+
for (const [name, selector] of Object.entries(
486+
options.selectors ?? {}
487+
)) {
488+
selectors[name] = (rootState: any, ...args: any[]) =>
489+
selector(
490+
selectState(rootState) ?? this.getInitialState(),
491+
...args
492+
)
493+
}
494+
selectorCache!.set(selectState, selectors)
495+
return selectors as any
496+
},
497+
get selectors() {
498+
return this.getSelectors(defaultSelectSlice)
499+
},
500+
} as any
501+
},
376502
}
377503
}

packages/toolkit/src/tests/createSlice.test.ts

+69-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { vi } from 'vitest'
2-
import type { PayloadAction } from '@reduxjs/toolkit'
2+
import type { PayloadAction, WithSlice } from '@reduxjs/toolkit'
3+
import { combineSlices } from '@reduxjs/toolkit'
34
import { createSlice, createAction } from '@reduxjs/toolkit'
45
import {
56
mockConsole,
@@ -449,4 +450,71 @@ describe('createSlice', () => {
449450
)
450451
})
451452
})
453+
describe('slice selectors', () => {
454+
const slice = createSlice({
455+
name: 'counter',
456+
initialState: 42,
457+
reducers: {},
458+
selectors: {
459+
selectSlice: (state) => state,
460+
selectMultiple: (state, multiplier: number) => state * multiplier,
461+
},
462+
})
463+
it('expects reducer under slice.name if no selectState callback passed', () => {
464+
const testState = {
465+
[slice.name]: slice.getInitialState(),
466+
}
467+
const { selectSlice, selectMultiple } = slice.selectors
468+
expect(selectSlice(testState)).toBe(slice.getInitialState())
469+
expect(selectMultiple(testState, 2)).toBe(slice.getInitialState() * 2)
470+
})
471+
it('allows passing a selector for a custom location', () => {
472+
const customState = {
473+
number: slice.getInitialState(),
474+
}
475+
const { selectSlice, selectMultiple } = slice.getSelectors(
476+
(state: typeof customState) => state.number
477+
)
478+
expect(selectSlice(customState)).toBe(slice.getInitialState())
479+
expect(selectMultiple(customState, 2)).toBe(slice.getInitialState() * 2)
480+
})
481+
})
482+
describe('slice injections', () => {
483+
it('uses injectInto to inject slice into combined reducer', () => {
484+
const slice = createSlice({
485+
name: 'counter',
486+
initialState: 42,
487+
reducers: {
488+
increment: (state) => ++state,
489+
},
490+
selectors: {
491+
selectSlice: (state) => state,
492+
selectMultiple: (state, multiplier: number) => state * multiplier,
493+
},
494+
})
495+
496+
const { increment } = slice.actions
497+
498+
const combinedReducer = combineSlices({
499+
static: slice.reducer,
500+
}).withLazyLoadedSlices<WithSlice<typeof slice>>()
501+
502+
const uninjectedState = combinedReducer(undefined, increment())
503+
504+
expect(uninjectedState.counter).toBe(undefined)
505+
506+
const injectedSlice = slice.injectInto(combinedReducer)
507+
508+
// selector returns initial state if undefined in real state
509+
expect(injectedSlice.selectors.selectSlice(uninjectedState)).toBe(
510+
slice.getInitialState()
511+
)
512+
513+
const injectedState = combinedReducer(undefined, increment())
514+
515+
expect(injectedSlice.selectors.selectSlice(injectedState)).toBe(
516+
slice.getInitialState() + 1
517+
)
518+
})
519+
})
452520
})

packages/toolkit/src/tests/createSlice.typetest.ts

+53
Original file line numberDiff line numberDiff line change
@@ -499,3 +499,56 @@ const value = actionCreators.anyKey
499499
return { doNothing, setData, slice }
500500
}
501501
}
502+
503+
/**
504+
* Test: slice selectors
505+
*/
506+
507+
{
508+
const sliceWithoutSelectors = createSlice({
509+
name: '',
510+
initialState: '',
511+
reducers: {},
512+
})
513+
514+
// @ts-expect-error
515+
sliceWithoutSelectors.selectors.foo
516+
517+
const sliceWithSelectors = createSlice({
518+
name: 'counter',
519+
initialState: { value: 0 },
520+
reducers: {
521+
increment: (state) => {
522+
state.value += 1
523+
},
524+
},
525+
selectors: {
526+
selectValue: (state) => state.value,
527+
selectMultiply: (state, multiplier: number) => state.value * multiplier,
528+
selectToFixed: (state) => state.value.toFixed(2),
529+
},
530+
})
531+
532+
const rootState = {
533+
[sliceWithSelectors.name]: sliceWithSelectors.getInitialState(),
534+
}
535+
536+
const { selectValue, selectMultiply, selectToFixed } =
537+
sliceWithSelectors.selectors
538+
539+
expectType<number>(selectValue(rootState))
540+
expectType<number>(selectMultiply(rootState, 2))
541+
expectType<string>(selectToFixed(rootState))
542+
543+
const nestedState = {
544+
nested: rootState,
545+
}
546+
547+
const nestedSelectors = sliceWithSelectors.getSelectors(
548+
(rootState: typeof nestedState) => rootState.nested.counter
549+
)
550+
551+
expectType<number>(nestedSelectors.selectValue(nestedState))
552+
expectType<number>(nestedSelectors.selectMultiply(nestedState, 2))
553+
expectType<string>(nestedSelectors.selectToFixed(nestedState))
554+
}

0 commit comments

Comments
 (0)