Skip to content

Commit 2955048

Browse files
authored
Merge pull request #3414 from EskiMojo14/action-creator-middleware
fixes #3413
2 parents d7ef297 + fc53a5a commit 2955048

10 files changed

+258
-7
lines changed

docs/api/actionCreatorMiddleware.mdx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
id: actionCreatorMiddleware
3+
title: Action Creator Middleware
4+
sidebar_label: Action Creator Middleware
5+
hide_title: true
6+
---
7+
8+
 
9+
10+
# Action Creator Middleware
11+
12+
A custom middleware that detects if an action creator has been mistakenly dispatched, instead of being called before dispatching.
13+
14+
A common mistake is to call `dispatch(actionCreator)` instead of `dispatch(actionCreator())`.
15+
This tends to "work" as the action creator has the static `type` property, but can lead to unexpected behaviour.
16+
17+
## Options
18+
19+
```ts no-transpile
20+
export interface ActionCreatorInvariantMiddlewareOptions {
21+
/**
22+
* The function to identify whether a value is an action creator.
23+
* The default checks for a function with a static type property and match method.
24+
*/
25+
isActionCreator?: (action: unknown) => action is Function & { type?: unknown }
26+
}
27+
```
28+
29+
## Exports
30+
31+
### `createActionCreatorInvariantMiddleware`
32+
33+
Creates an instance of the action creator check middleware, with the given options.
34+
35+
You will most likely not need to call this yourself, as `getDefaultMiddleware` already does so.
36+
Example:
37+
38+
```ts
39+
// file: reducer.ts noEmit
40+
41+
export default function (state = {}, action: any) {
42+
return state
43+
}
44+
45+
// file: store.ts
46+
47+
import {
48+
configureStore,
49+
createActionCreatorInvariantMiddleware,
50+
} from '@reduxjs/toolkit'
51+
import reducer from './reducer'
52+
53+
// Augment middleware to consider all functions with a static type property to be action creators
54+
const isActionCreator = (
55+
action: unknown
56+
): action is Function & { type: unknown } =>
57+
typeof action === 'function' && 'type' in action
58+
59+
const actionCreatorMiddleware = createActionCreatorInvariantMiddleware({
60+
isActionCreator,
61+
})
62+
63+
const store = configureStore({
64+
reducer,
65+
middleware: [actionCreatorMiddleware],
66+
})
67+
```

docs/api/getDefaultMiddleware.mdx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ It is preferable to use the chainable `.concat(...)` and `.prepend(...)` methods
7070

7171
One of the goals of Redux Toolkit is to provide opinionated defaults and prevent common mistakes. As part of that,
7272
`getDefaultMiddleware` includes some middleware that are added **in development builds of your app only** to
73-
provide runtime checks for two common issues:
73+
provide runtime checks for three common issues:
7474

7575
- [Immutability check middleware](./immutabilityMiddleware.mdx): deeply compares
7676
state values for mutations. It can detect mutations in reducers during a dispatch, and also mutations that occur between
@@ -82,13 +82,21 @@ provide runtime checks for two common issues:
8282
such as functions, Promises, Symbols, and other non-plain-JS-data values. When a non-serializable value is detected, a
8383
console error will be printed with the key path for where the non-serializable value was detected.
8484

85+
- [Action creator check middleware](./actionCreatorMiddleware.mdx): another custom middleware created specifically for use in Redux Toolkit.
86+
Identifies when an action creator was mistakenly dispatched without being called, and warns to console with the action type.
87+
8588
In addition to these development tool middleware, it also adds [`redux-thunk`](https://github.com/reduxjs/redux-thunk)
8689
by default, since thunks are the basic recommended side effects middleware for Redux.
8790

8891
Currently, the return value is:
8992

9093
```js
91-
const middleware = [thunk, immutableStateInvariant, serializableStateInvariant]
94+
const middleware = [
95+
actionCreatorInvariant,
96+
immutableStateInvariant,
97+
thunk,
98+
serializableStateInvariant,
99+
]
92100
```
93101

94102
### Production
@@ -153,10 +161,15 @@ interface SerializableStateInvariantMiddlewareOptions {
153161
// See "Serializability Middleware" page for definition
154162
}
155163

164+
interface ActionCreatorInvariantMiddlewareOptions {
165+
// See "Action Creator Middleware" page for definition
166+
}
167+
156168
interface GetDefaultMiddlewareOptions {
157169
thunk?: boolean | ThunkOptions
158170
immutableCheck?: boolean | ImmutableStateInvariantMiddlewareOptions
159171
serializableCheck?: boolean | SerializableStateInvariantMiddlewareOptions
172+
actionCreatorCheck?: boolean | ActionCreatorInvariantMiddlewareOptions
160173
}
161174

162175
function getDefaultMiddleware<S = any>(
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { Middleware } from 'redux'
2+
import { isActionCreator as isRTKAction } from './createAction'
3+
4+
export interface ActionCreatorInvariantMiddlewareOptions {
5+
/**
6+
* The function to identify whether a value is an action creator.
7+
* The default checks for a function with a static type property and match method.
8+
*/
9+
isActionCreator?: (action: unknown) => action is Function & { type?: unknown }
10+
}
11+
12+
export function getMessage(type?: unknown) {
13+
const splitType = type ? `${type}`.split('/') : []
14+
const actionName = splitType[splitType.length - 1] || 'actionCreator'
15+
return `Detected an action creator with type "${
16+
type || 'unknown'
17+
}" being dispatched.
18+
Make sure you're calling the action creator before dispatching, i.e. \`dispatch(${actionName}())\` instead of \`dispatch(${actionName})\`. This is necessary even if the action has no payload.`
19+
}
20+
21+
export function createActionCreatorInvariantMiddleware(
22+
options: ActionCreatorInvariantMiddlewareOptions = {}
23+
): Middleware {
24+
if (process.env.NODE_ENV === 'production') {
25+
return () => (next) => (action) => next(action)
26+
}
27+
const { isActionCreator = isRTKAction } = options
28+
return () => (next) => (action) => {
29+
if (isActionCreator(action)) {
30+
console.warn(getMessage(action.type))
31+
}
32+
return next(action)
33+
}
34+
}

packages/toolkit/src/createAction.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
IfVoid,
66
IsAny,
77
} from './tsHelpers'
8+
import { hasMatchFunction } from './tsHelpers'
89
import isPlainObject from './isPlainObject'
910

1011
/**
@@ -293,6 +294,20 @@ export function isAction(action: unknown): action is Action<unknown> {
293294
return isPlainObject(action) && 'type' in action
294295
}
295296

297+
/**
298+
* Returns true if value is an RTK-like action creator, with a static type property and match method.
299+
*/
300+
export function isActionCreator(
301+
action: unknown
302+
): action is BaseActionCreator<unknown, string> & Function {
303+
return (
304+
typeof action === 'function' &&
305+
'type' in action &&
306+
// hasMatchFunction only wants Matchers but I don't see the point in rewriting it
307+
hasMatchFunction(action as any)
308+
)
309+
}
310+
296311
/**
297312
* Returns true if value is an action with a string type and valid Flux Standard Action keys.
298313
*/

packages/toolkit/src/getDefaultMiddleware.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { Middleware, AnyAction } from 'redux'
22
import type { ThunkMiddleware } from 'redux-thunk'
33
import thunkMiddleware from 'redux-thunk'
4+
import type { ActionCreatorInvariantMiddlewareOptions } from './actionCreatorInvariantMiddleware'
5+
import { createActionCreatorInvariantMiddleware } from './actionCreatorInvariantMiddleware'
46
import type { ImmutableStateInvariantMiddlewareOptions } from './immutableStateInvariantMiddleware'
57
/* PROD_START_REMOVE_UMD */
68
import { createImmutableStateInvariantMiddleware } from './immutableStateInvariantMiddleware'
@@ -23,6 +25,7 @@ interface GetDefaultMiddlewareOptions {
2325
thunk?: boolean | ThunkOptions
2426
immutableCheck?: boolean | ImmutableStateInvariantMiddlewareOptions
2527
serializableCheck?: boolean | SerializableStateInvariantMiddlewareOptions
28+
actionCreatorCheck?: boolean | ActionCreatorInvariantMiddlewareOptions
2629
}
2730

2831
export type ThunkMiddlewareFor<
@@ -41,6 +44,7 @@ export type CurriedGetDefaultMiddleware<S = any> = <
4144
thunk: true
4245
immutableCheck: true
4346
serializableCheck: true
47+
actionCreatorCheck: true
4448
}
4549
>(
4650
options?: O
@@ -72,6 +76,7 @@ export function getDefaultMiddleware<
7276
thunk: true
7377
immutableCheck: true
7478
serializableCheck: true
79+
actionCreatorCheck: true
7580
}
7681
>(
7782
options: O = {} as O
@@ -80,6 +85,7 @@ export function getDefaultMiddleware<
8085
thunk = true,
8186
immutableCheck = true,
8287
serializableCheck = true,
88+
actionCreatorCheck = true,
8389
} = options
8490

8591
let middlewareArray = new MiddlewareArray<Middleware[]>()
@@ -120,6 +126,17 @@ export function getDefaultMiddleware<
120126
createSerializableStateInvariantMiddleware(serializableOptions)
121127
)
122128
}
129+
if (actionCreatorCheck) {
130+
let actionCreatorOptions: ActionCreatorInvariantMiddlewareOptions = {}
131+
132+
if (!isBoolean(actionCreatorCheck)) {
133+
actionCreatorOptions = actionCreatorCheck
134+
}
135+
136+
middlewareArray.unshift(
137+
createActionCreatorInvariantMiddleware(actionCreatorOptions)
138+
)
139+
}
123140
}
124141

125142
return middlewareArray as any

packages/toolkit/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export {
4040
createAction,
4141
getType,
4242
isAction,
43+
isActionCreator,
4344
isFSA as isFluxStandardAction,
4445
} from './createAction'
4546
export type {
@@ -78,6 +79,8 @@ export type {
7879
CaseReducerWithPrepare,
7980
SliceActionCreator,
8081
} from './createSlice'
82+
export type { ActionCreatorInvariantMiddlewareOptions } from './actionCreatorInvariantMiddleware'
83+
export { createActionCreatorInvariantMiddleware } from './actionCreatorInvariantMiddleware'
8184
export {
8285
// js
8386
createImmutableStateInvariantMiddleware,
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { ActionCreatorInvariantMiddlewareOptions } from '@internal/actionCreatorInvariantMiddleware'
2+
import { getMessage } from '@internal/actionCreatorInvariantMiddleware'
3+
import { createActionCreatorInvariantMiddleware } from '@internal/actionCreatorInvariantMiddleware'
4+
import type { Dispatch, MiddlewareAPI } from '@reduxjs/toolkit'
5+
import { createAction } from '@reduxjs/toolkit'
6+
7+
describe('createActionCreatorInvariantMiddleware', () => {
8+
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
9+
10+
afterEach(() => {
11+
consoleSpy.mockClear()
12+
})
13+
afterAll(() => {
14+
consoleSpy.mockRestore()
15+
})
16+
17+
const dummyAction = createAction('aSlice/anAction')
18+
19+
it('sends the action through the middleware chain', () => {
20+
const next: Dispatch = (action) => ({
21+
...action,
22+
returned: true,
23+
})
24+
const dispatch = createActionCreatorInvariantMiddleware()(
25+
{} as MiddlewareAPI
26+
)(next)
27+
28+
expect(dispatch(dummyAction())).toEqual({
29+
...dummyAction(),
30+
returned: true,
31+
})
32+
})
33+
34+
const makeActionTester = (
35+
options?: ActionCreatorInvariantMiddlewareOptions
36+
) =>
37+
createActionCreatorInvariantMiddleware(options)({} as MiddlewareAPI)(
38+
(action) => action
39+
)
40+
41+
it('logs a warning to console if an action creator is mistakenly dispatched', () => {
42+
const testAction = makeActionTester()
43+
44+
testAction(dummyAction())
45+
46+
expect(consoleSpy).not.toHaveBeenCalled()
47+
48+
testAction(dummyAction)
49+
50+
expect(consoleSpy).toHaveBeenLastCalledWith(getMessage(dummyAction.type))
51+
})
52+
53+
it('allows passing a custom predicate', () => {
54+
let predicateCalled = false
55+
const testAction = makeActionTester({
56+
isActionCreator(action): action is Function {
57+
predicateCalled = true
58+
return false
59+
},
60+
})
61+
testAction(dummyAction())
62+
expect(predicateCalled).toBe(true)
63+
})
64+
})

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,10 @@ describe('configureStore', () => {
7575
Object
7676
)
7777
expect(redux.applyMiddleware).toHaveBeenCalledWith(
78-
expect.any(Function), // thunk
7978
expect.any(Function), // immutableCheck
80-
expect.any(Function) // serializableCheck
79+
expect.any(Function), // thunk
80+
expect.any(Function), // serializableCheck
81+
expect.any(Function) // actionCreatorCheck
8182
)
8283
expect(devtools.composeWithDevTools).toHaveBeenCalled() // @remap-prod-remove-line-line
8384
expect(redux.createStore).toHaveBeenCalledWith(

0 commit comments

Comments
 (0)