Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 8 additions & 1 deletion src/configureStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { ThunkDispatch } from 'redux-thunk'

import isPlainObject from './isPlainObject'
import { getDefaultMiddleware } from './getDefaultMiddleware'
import { devModeWrapStore } from './developmentValidations'

const IS_PRODUCTION = process.env.NODE_ENV === 'production'

Expand Down Expand Up @@ -135,9 +136,15 @@ export function configureStore<S = any, A extends Action = AnyAction>(

const composedEnhancer = finalCompose(...storeEnhancers) as StoreEnhancer

return createStore(
const store: EnhancedStore<S, A> = createStore(
rootReducer,
preloadedState as DeepPartial<S>,
composedEnhancer
)

if (process.env.NODE_ENV !== 'production') {
return devModeWrapStore(store)
} else {
return store
}
}
40 changes: 40 additions & 0 deletions src/createActionListenerMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Middleware, MiddlewareAPI, Action, AnyAction } from 'redux'

interface ActionListenerMiddlewareListener<A extends Action = AnyAction> {
(action: A, api: MiddlewareAPI): void
}

export type CaseListeners = Record<
string,
ActionListenerMiddlewareListener<any>
>

export type CaseListenersCheck<Listeners extends CaseListeners> = {
[T in keyof Listeners]: Listeners[T] extends ActionListenerMiddlewareListener<
infer A
>
? A extends { type: T }
? Listeners[T]
: /*
Type is not matching the object key.
Return ActionListenerMiddlewareListener<Action<T>> to hint in the resulting error message that this is wrong.
*/ ActionListenerMiddlewareListener<
Action<T>
>
: never
}

export type ValidCaseListeners<Listeners extends CaseListeners> = Listeners &
CaseListenersCheck<Listeners>

export function createActionListenerMiddleware<Listeners extends CaseListeners>(
cases: ValidCaseListeners<Listeners>
): Middleware {
return api => next => action => {
const listener = cases[action.type]
if (listener) {
listener(action, api)
}
return next(action)
}
}
50 changes: 39 additions & 11 deletions src/createSlice.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Reducer } from 'redux'
import { Reducer, Middleware } from 'redux'
import {
createAction,
PayloadAction,
Expand All @@ -12,6 +12,13 @@ import {
ActionReducerMapBuilder,
executeReducerBuilderCallback
} from './mapBuilders'
import {
CaseListeners,
ValidCaseListeners,
createActionListenerMiddleware
} from './createActionListenerMiddleware'
import { IsUnspecifiedRecord } from './tsHelpers'
import { devModeWrapSlice } from './developmentValidations'

/**
* An action creator atttached to a slice.
Expand All @@ -24,7 +31,8 @@ export interface Slice<
State = any,
CaseReducers extends SliceCaseReducerDefinitions<State, PayloadActions> = {
[key: string]: any
}
},
Listeners extends CaseListeners = CaseListeners
> {
/**
* The slice name.
Expand All @@ -43,6 +51,8 @@ export interface Slice<
actions: CaseReducerActions<CaseReducers>

caseReducers: SliceDefinedCaseReducers<CaseReducers, State>

middleware: IsUnspecifiedRecord<Listeners, undefined, Middleware>
}

/**
Expand All @@ -53,7 +63,8 @@ export interface CreateSliceOptions<
CR extends SliceCaseReducerDefinitions<
State,
any
> = SliceCaseReducerDefinitions<State, any>
> = SliceCaseReducerDefinitions<State, any>,
Listeners extends CaseListeners = CaseListeners
> {
/**
* The slice's name. Used to namespace the generated action types.
Expand Down Expand Up @@ -82,6 +93,8 @@ export interface CreateSliceOptions<
extraReducers?:
| CaseReducers<NoInfer<State>, any>
| ((builder: ActionReducerMapBuilder<NoInfer<State>>) => void)

actionListeners?: ValidCaseListeners<Listeners>
}

type PayloadActions<Types extends keyof any = string> = Record<
Expand Down Expand Up @@ -191,19 +204,21 @@ function getType(slice: string, actionKey: string): string {
*/
export function createSlice<
State,
CaseReducers extends SliceCaseReducerDefinitions<State, any>
CaseReducers extends SliceCaseReducerDefinitions<State, any>,
Listeners extends CaseListeners
>(
options: CreateSliceOptions<State, CaseReducers> &
options: CreateSliceOptions<State, CaseReducers, Listeners> &
RestrictCaseReducerDefinitionsToMatchReducerAndPrepare<State, CaseReducers>
): Slice<State, CaseReducers>
): Slice<State, CaseReducers, Listeners>

// internal definition is a little less restrictive
export function createSlice<
State,
CaseReducers extends SliceCaseReducerDefinitions<State, any>
CaseReducers extends SliceCaseReducerDefinitions<State, any>,
Listeners extends CaseListeners
>(
options: CreateSliceOptions<State, CaseReducers>
): Slice<State, CaseReducers> {
options: CreateSliceOptions<State, CaseReducers, Listeners>
): Slice<State, CaseReducers, Listeners> {
const { name, initialState } = options
if (!name) {
throw new Error('`name` is a required option for createSlice')
Expand Down Expand Up @@ -246,10 +261,23 @@ export function createSlice<
const finalCaseReducers = { ...extraReducers, ...sliceCaseReducersByType }
const reducer = createReducer(initialState, finalCaseReducers as any)

return {
const actionListeners = options.actionListeners
const middleware =
actionListeners && Object.keys(actionListeners!).length > 0
? createActionListenerMiddleware(actionListeners as any)
: undefined

const slice = {
name,
reducer,
actions: actionCreators as any,
caseReducers: sliceCaseReducersByName as any
caseReducers: sliceCaseReducersByName as any,
middleware: middleware as any
}

if (process.env.NODE_ENV !== 'production') {
return devModeWrapSlice(slice)
} else {
return slice
}
}
88 changes: 88 additions & 0 deletions src/developmentValidations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { createAction } from './createAction'

interface ValidationMeta {
seenSlices: symbol[]
seenSliceNames: string[]
seenSliceMiddlewares: symbol[]
}

export const validate = createAction(
'__REDUX_TOOLKIT_DEVELOPMENT_MODE_VALIDATION__',
() => ({
payload: undefined,
meta: {
seenSlices: [],
seenSliceNames: [],
seenSliceMiddlewares: []
} as ValidationMeta
})
)

export function devModeWrapStore<S extends import('redux').Store>(store: S): S {
if (process.env.NODE_ENV !== 'production') {
store.dispatch(validate())
}
return store
}

export function devModeWrapSlice<S extends import('./createSlice').Slice>(
slice: S
): S {
if (process.env.NODE_ENV !== 'production') {
const sliceSymbol = Symbol(slice.name)
return {
...slice,
reducer: wrapReducer(slice.reducer),
middleware: slice.middleware
? wrapMiddleware(slice.middleware!)
: undefined
}

function wrapReducer(
reducer: import('redux').Reducer
): import('redux').Reducer {
return function(state: any, action: any) {
if (validate.match(action)) {
if (action.meta.seenSlices.includes(sliceSymbol)) {
console.warn(
`You use the same reducer (for slice "${slice.name}") twice - this is most likely a bug.`
)
}
if (action.meta.seenSliceNames.includes(slice.name)) {
console.warn(
`You use two slices with the same name "${slice.name}" - this is most likely a bug.`
)
}
if (
slice.middleware &&
!action.meta.seenSliceMiddlewares.includes(sliceSymbol)
) {
console.warn(
`Slice ${slice.name} has a middleware, but is is not being used. This is most likely a bug.`
)
}
action.meta.seenSlices.push(sliceSymbol)
action.meta.seenSliceNames.push(slice.name)
}
return reducer(state, action)
}
}
function wrapMiddleware(
middleware: import('redux').Middleware
): import('redux').Middleware {
return api => {
const boundWithApi = middleware(api)
return next => {
const boundWithNext = boundWithApi(next)
return action => {
if (validate.match(action)) {
action.meta.seenSliceMiddlewares.push(sliceSymbol)
}
return boundWithNext(action)
}
}
}
}
}
return slice
}
6 changes: 5 additions & 1 deletion src/serializableStateInvariantMiddleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,11 @@ describe('serializableStateInvariantMiddleware', () => {

// since default options are used, the `entries` function in `serializableObject` will cause the error
expect(log).toMatchInlineSnapshot(`
"A non-serializable value was detected in the state, in the path: \`testSlice.a.entries\`. Value: [Function: entries]
"A non-serializable value was detected in the state, in the path: \`testSlice.a.entries\`. Value: () => [
['first', 1],
['second', 'B!'],
['third', nestedSerializableObjectWithBadValue]
]
Take a look at the reducer(s) handling this action type: TEST_ACTION.
(See https://redux.js.org/faq/organizing-state#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state)
"
Expand Down
5 changes: 5 additions & 0 deletions src/serializableStateInvariantMiddleware.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import isPlainObject from './isPlainObject'
import { Middleware } from 'redux'
import { validate as devModeValidateAction } from './developmentValidations'

/**
* Returns true if the passed value is "plain", i.e. a value that is either
Expand Down Expand Up @@ -108,6 +109,10 @@ export function createSerializableStateInvariantMiddleware(
): Middleware {
const { isSerializable = isPlain, getEntries, ignoredActions = [] } = options

if (process.env.NODE_ENV !== 'production') {
ignoredActions.push(devModeValidateAction.type)
}

return storeAPI => next => action => {
if (ignoredActions.length && ignoredActions.indexOf(action.type) !== -1) {
return next(action)
Expand Down
6 changes: 6 additions & 0 deletions src/tsHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,9 @@ export type IsUnknownOrNonInferrable<T, True, False> = AtLeastTS35<
IsUnknown<T, True, False>,
IsEmptyObj<T, True, False>
>

export type IsUnspecifiedRecord<
T extends Record<string, any>,
True,
False
> = string extends keyof T ? True : False