Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

combineSlices implementation #3297

Merged
merged 55 commits into from
Apr 18, 2023
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
3c3bc4d
experiment with API typing
EskiMojo14 Mar 26, 2023
a50c968
support reducer map objects
EskiMojo14 Mar 26, 2023
00762a2
Prevent undeclared keys in reducer maps
EskiMojo14 Mar 26, 2023
5b81a3a
basic implementation
EskiMojo14 Mar 26, 2023
1397a52
export from entry point
EskiMojo14 Mar 26, 2023
075811e
rm inline typetest
EskiMojo14 Mar 26, 2023
99945eb
throw dev error if new reducer is injected when one already exists (w…
EskiMojo14 Mar 26, 2023
60d403b
typetest
EskiMojo14 Mar 26, 2023
7b4d8e0
more typetests
EskiMojo14 Mar 26, 2023
b3d4164
implement suggestions
EskiMojo14 Mar 26, 2023
818c595
add tests and fix oversight in injectSlices
EskiMojo14 Mar 26, 2023
6f1cbdf
use a Proxy to ensure injected reducers have state in selector
EskiMojo14 Mar 27, 2023
42db406
test result of selector instead of inside selector
EskiMojo14 Mar 27, 2023
b5db06a
syntax
EskiMojo14 Mar 27, 2023
8d5c282
add selectState parameter to handle nested reducers
Mar 27, 2023
b743008
use type assertion instead of ts-ignore
Mar 27, 2023
8ad0eb5
some JSDoc
Mar 27, 2023
a918ebd
handle RTKQ instances
Mar 27, 2023
bfdb10f
define original once and just attach to selector
Mar 27, 2023
8fc0d5d
throw an error when reducer returns undefined when called in state proxy
Mar 27, 2023
0c0d116
cache proxy creation, and use a proxy to mark a reducer as replaceable
EskiMojo14 Mar 27, 2023
327577f
export markReplaceable from package
EskiMojo14 Mar 27, 2023
43eb309
only allow injection of one slice/api at a time, and add config to al…
Mar 28, 2023
891a9f0
rename StaticState to InitialState, now it can be injected into
Mar 28, 2023
7058b93
injectSlice -> inject, Slice -> SliceLike, Api -> ApiLike, allowRepla…
Mar 28, 2023
cdb1e00
match RTKQ overrideExisting behaviour
Mar 28, 2023
7cdaf5b
fix test to match new behaviour
Mar 28, 2023
bac5361
remove unused export
Mar 28, 2023
7d7616c
no longer require slice to be declared before injection
Apr 2, 2023
157b88a
rework selector inference
Apr 2, 2023
1a218cf
check reducer matches
Apr 2, 2023
bd5d4ff
begin experimenting with slice selectors
Apr 6, 2023
c36c9dc
tests
Apr 6, 2023
d1ae818
default to ID function for getSelectors without selectState
Apr 6, 2023
deecec7
cache selectors
Apr 6, 2023
2731434
rm TODO
Apr 6, 2023
792c944
injectInto implementation
Apr 6, 2023
36503dc
more accurate typing
Apr 6, 2023
4e4a3a1
pass arguments through
Apr 6, 2023
32d1964
more tests
Apr 6, 2023
34bf78b
remove index signature from selectors generic
Apr 6, 2023
bccf47f
Merge pull request #1 from EskiMojo14/combine-slices-integrated
EskiMojo14 Apr 6, 2023
6816c1a
remove constraint on lazy
Apr 7, 2023
d59b26a
add optional name parameter for injectInto
Apr 7, 2023
a285bf8
adjust slice option
Apr 7, 2023
af875ae
add inject config to injectInto
Apr 7, 2023
e3e8c2d
JSDoc
Apr 7, 2023
ef07e12
custom name test
Apr 7, 2023
37ab272
add reducerPath option to slice
Apr 7, 2023
e92c7db
fix tests
Apr 7, 2023
737d5cc
simplify injectInto implementation
Apr 7, 2023
e52f609
throw if selectState returns undefined in an uninjected slice
Apr 7, 2023
0a23a5c
rm WithApi export
Apr 7, 2023
fbf8c24
don't throw error in production
Apr 8, 2023
8b2c55d
delete injectableCombineReducers.example.ts
Apr 12, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions packages/toolkit/src/combineSlices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import type {
CombinedState,
AnyAction,
Reducer,
StateFromReducersMapObject,
} from 'redux'
import { combineReducers } from 'redux'
import type { Slice } from './createSlice'
import type {
Id,
UnionToIntersection,
WithOptionalProp,
WithRequiredProp,
} from './tsHelpers'
import { safeAssign } from './tsHelpers'

type AnySlice = Slice<any, any, any>

type ReducerMap = Record<string, Reducer>

type SliceState<Sl extends AnySlice> = Sl extends Slice<infer State, any, any>
? State
: never

type SliceName<Sl extends AnySlice> = Sl extends Slice<any, any, infer Name>
? Name
: never

export type WithSlice<Sl extends AnySlice> = Id<
{
[K in SliceName<Sl>]: SliceState<Sl>
}
>

// only allow injection of slices we've already declared
type LazyLoadedSlice<LazyLoadedState extends Record<string, unknown>> = {
[Name in keyof LazyLoadedState]: Name extends string
? Slice<LazyLoadedState[Name], any, Name>
: never
}[keyof LazyLoadedState]

type LazyLoadedReducerMap<LazyLoadedState extends Record<string, unknown>> = {
[Name in keyof LazyLoadedState]?: Reducer<LazyLoadedState[Name]>
}

type CombinedSliceState<
StaticState,
LazyLoadedState extends Record<string, unknown> = {},
InjectedKeys extends keyof LazyLoadedState = never
> = Id<
// TODO: use PreloadedState generic instead
CombinedState<
StaticState & WithRequiredProp<Partial<LazyLoadedState>, InjectedKeys>
>
>

// Prevent undeclared keys in reducer maps
type ValidateReducerMaps<
LazyLoadedState extends Record<string, unknown>,
Slices extends [
LazyLoadedSlice<LazyLoadedState> | LazyLoadedReducerMap<LazyLoadedState>,
...Array<
LazyLoadedSlice<LazyLoadedState> | LazyLoadedReducerMap<LazyLoadedState>
>
]
> = Slices &
{
[Index in keyof Slices]: Slices[Index] extends AnySlice
? {}
: {
[Name in keyof Slices[Index]]: Name extends keyof LazyLoadedState
? Reducer
: never
}
}

type NewKeys<
LazyLoadedState extends Record<string, unknown>,
Slices extends [
LazyLoadedSlice<LazyLoadedState> | LazyLoadedReducerMap<LazyLoadedState>,
...Array<
LazyLoadedSlice<LazyLoadedState> | LazyLoadedReducerMap<LazyLoadedState>
>
]
> = Slices[number] extends infer Slice
? Slice extends AnySlice
? SliceName<Slice>
: keyof Slice
: never

interface CombinedSliceReducer<
StaticState,
LazyLoadedState extends Record<string, unknown> = {},
InjectedKeys extends keyof LazyLoadedState = never
> extends Reducer<
CombinedSliceState<StaticState, LazyLoadedState, InjectedKeys>,
AnyAction
> {
withLazyLoadedSlices<
Lazy extends Record<string, unknown> = {}
>(): CombinedSliceReducer<StaticState, LazyLoadedState & Lazy, InjectedKeys>

injectSlices<
Slices extends [
LazyLoadedSlice<LazyLoadedState> | LazyLoadedReducerMap<LazyLoadedState>,
...Array<
LazyLoadedSlice<LazyLoadedState> | LazyLoadedReducerMap<LazyLoadedState>
>
]
>(
...slices: ValidateReducerMaps<LazyLoadedState, Slices>
): CombinedSliceReducer<
StaticState,
LazyLoadedState,
InjectedKeys | NewKeys<LazyLoadedState, Slices>
>

// TODO: deal with nested state?
selector<
Selected,
State extends CombinedSliceState<
StaticState,
LazyLoadedState,
InjectedKeys
>,
Args extends any[]
>(
selectorFn: (state: State, ...args: Args) => Selected
): (state: WithOptionalProp<State, InjectedKeys>, ...args: Args) => Selected
}

type StaticState<
Slices extends [AnySlice | ReducerMap, ...Array<AnySlice | ReducerMap>]
> = UnionToIntersection<
Slices[number] extends infer Slice
? Slice extends AnySlice
? WithSlice<Slice>
: StateFromReducersMapObject<Slice>
: never
>

const isSlice = (maybeSlice: AnySlice | ReducerMap): maybeSlice is AnySlice =>
typeof maybeSlice.actions === 'object'

export function combineSlices<
Slices extends [AnySlice | ReducerMap, ...Array<AnySlice | ReducerMap>]
>(...slices: Slices): CombinedSliceReducer<Id<StaticState<Slices>>> {
const reducerMap = slices.reduce<Record<string, Reducer>>((map, slice) => {
EskiMojo14 marked this conversation as resolved.
Show resolved Hide resolved
if (isSlice(slice)) {
map[slice.name] = slice.reducer
} else {
for (const [name, reducer] of Object.entries(map)) {
map[name] = reducer
}
}
return map
}, {})

const getReducer = () => combineReducers(reducerMap)

let reducer = getReducer()

function combinedReducer(state: Record<string, unknown>, action: AnyAction) {
return reducer(state, action)
}

combinedReducer.withLazyLoadedSlices = () => combinedReducer

const injectReducer = (name: string, reducer: Reducer) => {
if (process.env.NODE_ENV !== 'production') {
const currentReducer = reducerMap[name]
if (currentReducer && currentReducer !== reducer) {
throw new Error(
`Name '${name}' has already been injected with different reducer instance`
)
}
}
reducerMap[name] = reducer
}

combinedReducer.injectSlices = (...slices: Array<AnySlice | ReducerMap>) => {
slices.forEach((slice) => {
if (isSlice(slice)) {
injectReducer(slice.name, slice.reducer)
} else {
for (const [name, reducer] of Object.entries(slice)) {
injectReducer(name, reducer)
}
}
})
reducer = getReducer()
}

combinedReducer.selector =
<State, Args extends any[]>(
selectorFn: (state: State, ...args: Args) => any
) =>
(state: State, ...args: Args) =>
// TODO: ensure injected reducers have state
selectorFn(state, ...args)

return combinedReducer as any
}
4 changes: 4 additions & 0 deletions packages/toolkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,7 @@ export {
autoBatchEnhancer,
} from './autoBatchEnhancer'
export type { AutoBatchOptions } from './autoBatchEnhancer'

export { combineSlices } from './combineSlices'

export type { WithSlice } from './combineSlices'
78 changes: 78 additions & 0 deletions packages/toolkit/src/tests/combineSlices.typetest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/* eslint-disable no-lone-blocks */
import type { Reducer, Slice, WithSlice } from '@reduxjs/toolkit'
import { combineSlices } from '@reduxjs/toolkit'
import { expectExactType, expectType } from './helpers'

declare const fooSlice: Slice<true, {}, 'foo'>

declare const barSlice: Slice<true, {}, 'bar'>

declare const bazReducer: Reducer<true>
EskiMojo14 marked this conversation as resolved.
Show resolved Hide resolved

/**
* Test: combineSlices correctly combines static state
*/
{
const rootReducer = combineSlices(fooSlice, barSlice, { baz: bazReducer })
expectType<{ foo: true; bar: true; baz: true }>(
rootReducer(undefined, { type: '' })
)
}

/**
* Test: withLazyLoadedSlices adds partial to state
*/
{
const rootReducer =
combineSlices(fooSlice).withLazyLoadedSlices<WithSlice<typeof barSlice>>()
expectExactType<true | undefined>(true)(
rootReducer(undefined, { type: '' }).bar
)
}

/**
* Test: injectSlices marks injected keys as required
*/
{
const rootReducer = combineSlices(fooSlice).withLazyLoadedSlices<
WithSlice<typeof barSlice> & { baz: true }
>()

expectExactType<true | undefined>(true)(
rootReducer(undefined, { type: '' }).bar
)
expectExactType<true | undefined>(true)(
rootReducer(undefined, { type: '' }).baz
)

const injectedReducer = rootReducer.injectSlices(barSlice)
expectExactType<true>(true)(injectedReducer(undefined, { type: '' }).bar)

const injectedReducer2 = rootReducer.injectSlices({ baz: bazReducer })
expectExactType<true>(true)(injectedReducer2(undefined, { type: '' }).baz)
}

/**
* Test: selector() allows defining selectors with injected reducers defined
*/
{
const rootReducer = combineSlices(fooSlice).withLazyLoadedSlices<
WithSlice<typeof barSlice> & { baz: true }
>()

type RootState = ReturnType<typeof rootReducer>

const withoutInjection = (state: RootState) => state.bar
EskiMojo14 marked this conversation as resolved.
Show resolved Hide resolved

expectExactType<true | undefined>(true)(
withoutInjection(rootReducer(undefined, { type: '' }))
)

const withInjection = rootReducer
.injectSlices(barSlice)
.selector((state) => state.bar)

expectExactType<true>(true)(
withInjection(rootReducer(undefined, { type: '' }))
)
}
13 changes: 13 additions & 0 deletions packages/toolkit/src/tsHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import type { Middleware, StoreEnhancer } from 'redux'
import type { MiddlewareArray } from './utils'

export function safeAssign<T extends object>(
target: T,
...args: Array<Partial<NoInfer<T>>>
) {
Object.assign(target, ...args)
}

/**
* return True if T is `any`, otherwise return False
* taken from https://github.com/joonhocho/tsdef
Expand Down Expand Up @@ -122,6 +129,12 @@ export type NoInfer<T> = [T][T extends any ? 0 : never]

export type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>

export type WithRequiredProp<T, K extends keyof T> = Omit<T, K> &
Required<Pick<T, K>>

export type WithOptionalProp<T, K extends keyof T> = Omit<T, K> &
Partial<Pick<T, K>>

export interface TypeGuard<T> {
(value: any): value is T
}
Expand Down