Skip to content

Commit cfab6e5

Browse files
authored
Merge pull request #3878 from reduxjs/selectors-housekeeping
2 parents 815a705 + 1789d80 commit cfab6e5

File tree

8 files changed

+217
-91
lines changed

8 files changed

+217
-91
lines changed

docs/api/createSlice.mdx

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,6 @@ const counterSlice = createSlice({
394394
})
395395
```
396396

397-
<<<<<<< HEAD
398397
This cycle can be fixed by providing an explicit return type for the selector:
399398

400399
```ts no-transpile
@@ -440,10 +439,6 @@ const counterSlice = createSlice({
440439

441440
:::
442441

443-
=======
444-
445-
> > > > > > > master
446-
447442
## Return Value
448443

449444
`createSlice` will return an object that looks like:
@@ -518,6 +513,40 @@ const { selectValue } = counterSlice.selectors
518513
console.log(selectValue({ counter: { value: 2 } })) // 2
519514
```
520515
516+
:::note
517+
518+
The original selector passed is attached to the wrapped selector as `.unwrapped`. For example:
519+
520+
```ts
521+
import { createSlice, createSelector } from '@reduxjs/toolkit'
522+
523+
interface CounterState {
524+
value: number
525+
}
526+
527+
const counterSlice = createSlice({
528+
name: 'counter',
529+
initialState: { value: 0 } as CounterState,
530+
reducers: {
531+
// omitted
532+
},
533+
selectors: {
534+
selectDouble: createSelector(
535+
(sliceState: CounterState) => sliceState.value,
536+
(value) => value * 2
537+
),
538+
},
539+
})
540+
541+
const { selectDouble } = counterSlice.selectors
542+
543+
console.log(selectDouble({ counter: { value: 2 } })) // 4
544+
console.log(selectDouble({ counter: { value: 3 } })) // 6
545+
console.log(selectDouble.unwrapped.recomputations) // 2
546+
```
547+
548+
:::
549+
521550
#### `getSelectors`
522551
523552
`slice.getSelectors` is called with a single parameter, a `selectState` callback. This function should receive the store root state (or whatever you expect to call the resulting selectors with) and return the slice state.

docs/rtk-query/api/created-api/api-slice-utils.mdx

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -259,21 +259,11 @@ function selectInvalidatedBy(
259259
A function that can select query parameters to be invalidated.
260260

261261
The function accepts two arguments
262-
<<<<<<< HEAD
263-
264-
=======
265-
266-
> > > > > > > master
267262

268263
- the root state and
269264
- the cache tags to be invalidated.
270265

271266
It returns an array that contains
272-
<<<<<<< HEAD
273-
274-
=======
275-
276-
> > > > > > > master
277267

278268
- the endpoint name,
279269
- the original args and

packages/toolkit/src/combineSlices.ts

Lines changed: 27 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
UnionToIntersection,
99
WithOptionalProp,
1010
} from './tsHelpers'
11+
import { emplace } from './utils'
1112

1213
type SliceLike<ReducerPath extends string, State> = {
1314
reducerPath: ReducerPath
@@ -330,37 +331,34 @@ const stateProxyMap = new WeakMap<object, object>()
330331
const createStateProxy = <State extends object>(
331332
state: State,
332333
reducerMap: Partial<Record<string, Reducer>>
333-
) => {
334-
let proxy = stateProxyMap.get(state)
335-
if (!proxy) {
336-
proxy = new Proxy(state, {
337-
get: (target, prop, receiver) => {
338-
if (prop === ORIGINAL_STATE) return target
339-
const result = Reflect.get(target, prop, receiver)
340-
if (typeof result === 'undefined') {
341-
const reducer = reducerMap[prop.toString()]
342-
if (reducer) {
343-
// ensure action type is random, to prevent reducer treating it differently
344-
const reducerResult = reducer(undefined, { type: nanoid() })
345-
if (typeof reducerResult === 'undefined') {
346-
throw new Error(
347-
`The slice reducer for key "${prop.toString()}" returned undefined when called for selector(). ` +
348-
`If the state passed to the reducer is undefined, you must ` +
349-
`explicitly return the initial state. The initial state may ` +
350-
`not be undefined. If you don't want to set a value for this reducer, ` +
351-
`you can use null instead of undefined.`
352-
)
334+
) =>
335+
emplace(stateProxyMap, state, {
336+
insert: () =>
337+
new Proxy(state, {
338+
get: (target, prop, receiver) => {
339+
if (prop === ORIGINAL_STATE) return target
340+
const result = Reflect.get(target, prop, receiver)
341+
if (typeof result === 'undefined') {
342+
const reducer = reducerMap[prop.toString()]
343+
if (reducer) {
344+
// ensure action type is random, to prevent reducer treating it differently
345+
const reducerResult = reducer(undefined, { type: nanoid() })
346+
if (typeof reducerResult === 'undefined') {
347+
throw new Error(
348+
`The slice reducer for key "${prop.toString()}" returned undefined when called for selector(). ` +
349+
`If the state passed to the reducer is undefined, you must ` +
350+
`explicitly return the initial state. The initial state may ` +
351+
`not be undefined. If you don't want to set a value for this reducer, ` +
352+
`you can use null instead of undefined.`
353+
)
354+
}
355+
return reducerResult
353356
}
354-
return reducerResult
355357
}
356-
}
357-
return result
358-
},
359-
})
360-
stateProxyMap.set(state, proxy)
361-
}
362-
return proxy as State
363-
}
358+
return result
359+
},
360+
}),
361+
}) as State
364362

365363
const original = (state: any) => {
366364
if (!isStateProxy(state)) {

packages/toolkit/src/createSlice.ts

Lines changed: 56 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Action, UnknownAction, Reducer } from 'redux'
2+
import type { Selector } from 'reselect'
23
import type {
34
ActionCreatorWithoutPayload,
45
PayloadAction,
@@ -25,6 +26,7 @@ import type {
2526
OverrideThunkApiConfigs,
2627
} from './createAsyncThunk'
2728
import { createAsyncThunk as _createAsyncThunk } from './createAsyncThunk'
29+
import { emplace } from './utils'
2830

2931
const asyncThunkSymbol = Symbol.for('rtk-slice-createasyncthunk')
3032
// type is annotated because it's too long to infer
@@ -531,6 +533,14 @@ type SliceDefinedCaseReducers<CaseReducers extends SliceCaseReducers<any>> = {
531533
: never
532534
}
533535

536+
type RemappedSelector<S extends Selector, NewState> = S extends Selector<
537+
any,
538+
infer R,
539+
infer P
540+
>
541+
? Selector<NewState, R, P> & { unwrapped: S }
542+
: never
543+
534544
/**
535545
* Extracts the final selector type from the `selectors` object.
536546
*
@@ -541,10 +551,10 @@ type SliceDefinedSelectors<
541551
Selectors extends SliceSelectors<State>,
542552
RootState
543553
> = {
544-
[K in keyof Selectors as string extends K ? never : K]: (
545-
rootState: RootState,
546-
...args: Tail<Parameters<Selectors[K]>>
547-
) => ReturnType<Selectors[K]>
554+
[K in keyof Selectors as string extends K ? never : K]: RemappedSelector<
555+
Selectors[K],
556+
RootState
557+
>
548558
}
549559

550560
/**
@@ -756,35 +766,26 @@ export function buildCreateSlice({ creators }: BuildCreateSliceConfig = {}) {
756766
return _reducer.getInitialState()
757767
},
758768
getSelectors(selectState: (rootState: any) => State = selectSelf) {
759-
let selectorCache = injectedSelectorCache.get(this)
760-
if (!selectorCache) {
761-
selectorCache = new WeakMap()
762-
injectedSelectorCache.set(this, selectorCache)
763-
}
764-
let cached = selectorCache.get(selectState)
765-
if (!cached) {
766-
cached = {}
767-
for (const [name, selector] of Object.entries(
768-
options.selectors ?? {}
769-
)) {
770-
cached[name] = (rootState: any, ...args: any[]) => {
771-
let sliceState = selectState.call(this, rootState)
772-
if (typeof sliceState === 'undefined') {
773-
// check if injectInto has been called
774-
if (this !== slice) {
775-
sliceState = this.getInitialState()
776-
} else if (process.env.NODE_ENV !== 'production') {
777-
throw new Error(
778-
'selectState returned undefined for an uninjected slice reducer'
779-
)
780-
}
781-
}
782-
return selector(sliceState, ...args)
769+
const selectorCache = emplace(injectedSelectorCache, this, {
770+
insert: () => new WeakMap(),
771+
})
772+
773+
return emplace(selectorCache, selectState, {
774+
insert: () => {
775+
const map: Record<string, Selector<any, any>> = {}
776+
for (const [name, selector] of Object.entries(
777+
options.selectors ?? {}
778+
)) {
779+
map[name] = wrapSelector(
780+
this,
781+
selector,
782+
selectState,
783+
this !== slice
784+
)
783785
}
784-
}
785-
selectorCache.set(selectState, cached)
786-
}
787-
return cached as any
786+
return map
787+
},
788+
}) as any
788789
},
789790
selectSlice(state) {
790791
let sliceState = state[this.reducerPath]
@@ -816,6 +817,29 @@ export function buildCreateSlice({ creators }: BuildCreateSliceConfig = {}) {
816817
}
817818
}
818819

820+
function wrapSelector<State, NewState, S extends Selector<State>>(
821+
slice: Slice,
822+
selector: S,
823+
selectState: Selector<NewState, State>,
824+
injected?: boolean
825+
) {
826+
function wrapper(rootState: NewState, ...args: any[]) {
827+
let sliceState = selectState.call(slice, rootState)
828+
if (typeof sliceState === 'undefined') {
829+
if (injected) {
830+
sliceState = slice.getInitialState()
831+
} else if (process.env.NODE_ENV !== 'production') {
832+
throw new Error(
833+
'selectState returned undefined for an uninjected slice reducer'
834+
)
835+
}
836+
}
837+
return selector(sliceState, ...args)
838+
}
839+
wrapper.unwrapped = selector
840+
return wrapper as RemappedSelector<S, NewState>
841+
}
842+
819843
/**
820844
* A function that accepts an initial state, an object full of reducer
821845
* functions, and a "slice name", and automatically generates

packages/toolkit/src/dynamicMiddleware/index.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { compose } from 'redux'
77
import { createAction, isAction } from '../createAction'
88
import { isAllOf } from '../matchers'
99
import { nanoid } from '../nanoid'
10-
import { find } from '../utils'
10+
import { emplace, find } from '../utils'
1111
import type {
1212
WithMiddleware,
1313
AddMiddleware,
@@ -69,15 +69,8 @@ export const createDynamicMiddleware = <
6969
) as AddMiddleware<State, Dispatch>
7070

7171
const getFinalMiddleware: Middleware<{}, State, Dispatch> = (api) => {
72-
const appliedMiddleware = Array.from(middlewareMap.values()).map(
73-
(entry) => {
74-
let applied = entry.applied.get(api)
75-
if (!applied) {
76-
applied = entry.middleware(api)
77-
entry.applied.set(api, applied)
78-
}
79-
return applied
80-
}
72+
const appliedMiddleware = Array.from(middlewareMap.values()).map((entry) =>
73+
emplace(entry.applied, api, { insert: () => entry.middleware(api) })
8174
)
8275
return compose(...appliedMiddleware)
8376
}

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -466,12 +466,15 @@ describe('createSlice', () => {
466466
reducers: {},
467467
selectors: {
468468
selectSlice: (state) => state,
469-
selectMultiple: (state, multiplier: number) => state * multiplier,
469+
selectMultiple: Object.assign(
470+
(state: number, multiplier: number) => state * multiplier,
471+
{ test: 0 }
472+
),
470473
},
471474
})
472-
it('expects reducer under slice.name if no selectState callback passed', () => {
475+
it('expects reducer under slice.reducerPath if no selectState callback passed', () => {
473476
const testState = {
474-
[slice.name]: slice.getInitialState(),
477+
[slice.reducerPath]: slice.getInitialState(),
475478
}
476479
const { selectSlice, selectMultiple } = slice.selectors
477480
expect(selectSlice(testState)).toBe(slice.getInitialState())
@@ -487,6 +490,9 @@ describe('createSlice', () => {
487490
expect(selectSlice(customState)).toBe(slice.getInitialState())
488491
expect(selectMultiple(customState, 2)).toBe(slice.getInitialState() * 2)
489492
})
493+
it('allows accessing properties on the selector', () => {
494+
expect(slice.selectors.selectMultiple.unwrapped.test).toBe(0)
495+
})
490496
})
491497
describe('slice injections', () => {
492498
it('uses injectInto to inject slice into combined reducer', () => {
@@ -580,7 +586,9 @@ describe('createSlice', () => {
580586
initialState: [] as any[],
581587
reducers: (create) => ({ thunk: create.asyncThunk(() => {}) }),
582588
})
583-
).toThrowErrorMatchingInlineSnapshot('"Cannot use `create.asyncThunk` in the built-in `createSlice`. Use `buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })` to create a customised version of `createSlice`."')
589+
).toThrowErrorMatchingInlineSnapshot(
590+
'"Cannot use `create.asyncThunk` in the built-in `createSlice`. Use `buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })` to create a customised version of `createSlice`."'
591+
)
584592
})
585593
const createThunkSlice = buildCreateSlice({
586594
creators: { asyncThunk: asyncThunkCreator },

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -540,7 +540,10 @@ const value = actionCreators.anyKey
540540
selectors: {
541541
selectValue: (state) => state.value,
542542
selectMultiply: (state, multiplier: number) => state.value * multiplier,
543-
selectToFixed: (state) => state.value.toFixed(2),
543+
selectToFixed: Object.assign(
544+
(state: { value: number }) => state.value.toFixed(2),
545+
{ static: true }
546+
),
544547
},
545548
})
546549

@@ -555,6 +558,8 @@ const value = actionCreators.anyKey
555558
expectType<number>(selectMultiply(rootState, 2))
556559
expectType<string>(selectToFixed(rootState))
557560

561+
expectType<boolean>(selectToFixed.unwrapped.static)
562+
558563
const nestedState = {
559564
nested: rootState,
560565
}

0 commit comments

Comments
 (0)