Skip to content

Commit a5ff5f4

Browse files
committed
display a warning if immutableStateInvariantMiddleware or serializableStateInvariantMiddleware take too long
1 parent 36b25fc commit a5ff5f4

6 files changed

+196
-56
lines changed

etc/redux-toolkit.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ export interface SerializableStateInvariantMiddlewareOptions {
251251
ignoredActions?: string[];
252252
ignoredPaths?: string[];
253253
isSerializable?: (value: any) => boolean;
254+
warnAfter?: number;
254255
}
255256

256257
// @alpha (undocumented)

src/immutableStateInvariantMiddleware.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
trackForMutations,
66
ImmutableStateInvariantMiddlewareOptions
77
} from './immutableStateInvariantMiddleware'
8+
import { mockConsole, createConsole, getLog } from 'console-testing-library'
89

910
describe('createImmutableStateInvariantMiddleware', () => {
1011
let state: { foo: { bar: number[]; baz: string } }
@@ -121,6 +122,42 @@ describe('createImmutableStateInvariantMiddleware', () => {
121122
dispatch({ type: 'SOME_ACTION' })
122123
}).not.toThrow()
123124
})
125+
126+
it('Should print a warning if execution takes too long', () => {
127+
state.foo.bar = new Array(10000).fill({ value: 'more' })
128+
129+
const next: Dispatch = action => action
130+
131+
const dispatch = middleware({ warnAfter: 4 })(next)
132+
133+
const restore = mockConsole(createConsole())
134+
try {
135+
dispatch({ type: 'SOME_ACTION' })
136+
expect(getLog().log).toMatch(
137+
/^ImmutableStateInvariantMiddleware took \d*ms, which is more than the warning threshold of 4ms./
138+
)
139+
} finally {
140+
restore()
141+
}
142+
})
143+
144+
it('Should not print a warning if "next" takes too long', () => {
145+
const next: Dispatch = action => {
146+
const started = Date.now()
147+
while (Date.now() - started < 8) {}
148+
return action
149+
}
150+
151+
const dispatch = middleware({ warnAfter: 4 })(next)
152+
153+
const restore = mockConsole(createConsole())
154+
try {
155+
dispatch({ type: 'SOME_ACTION' })
156+
expect(getLog().log).toEqual('')
157+
} finally {
158+
restore()
159+
}
160+
})
124161
})
125162

126163
describe('trackForMutations', () => {

src/immutableStateInvariantMiddleware.ts

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Middleware } from 'redux'
2+
import { getTimeMeasureUtils } from './utils'
23

34
type EntryProcessor = (key: string, value: any) => any
45

@@ -174,6 +175,7 @@ type IsImmutableFunc = (value: any) => boolean
174175
export interface ImmutableStateInvariantMiddlewareOptions {
175176
isImmutable?: IsImmutableFunc
176177
ignoredPaths?: string[]
178+
warnAfter?: number
177179
}
178180

179181
export function createImmutableStateInvariantMiddleware(
@@ -183,7 +185,11 @@ export function createImmutableStateInvariantMiddleware(
183185
return () => next => action => next(action)
184186
}
185187

186-
const { isImmutable = isImmutableDefault, ignoredPaths } = options
188+
const {
189+
isImmutable = isImmutableDefault,
190+
ignoredPaths,
191+
warnAfter = 32
192+
} = options
187193
const track = trackForMutations.bind(null, isImmutable, ignoredPaths)
188194

189195
return ({ getState }) => {
@@ -192,39 +198,51 @@ export function createImmutableStateInvariantMiddleware(
192198

193199
let result
194200
return next => action => {
195-
state = getState()
196-
197-
result = tracker.detectMutations()
198-
// Track before potentially not meeting the invariant
199-
tracker = track(state)
200-
201-
invariant(
202-
!result.wasMutated,
203-
`A state mutation was detected between dispatches, in the path '${(
204-
result.path || []
205-
).join(
206-
'.'
207-
)}'. This may cause incorrect behavior. (http://redux.js.org/docs/Troubleshooting.html#never-mutate-reducer-arguments)`
201+
const measureUtils = getTimeMeasureUtils(
202+
warnAfter,
203+
'ImmutableStateInvariantMiddleware'
208204
)
209205

210-
const dispatchedAction = next(action)
211-
state = getState()
206+
measureUtils.measureTime(() => {
207+
state = getState()
212208

213-
result = tracker.detectMutations()
214-
// Track before potentially not meeting the invariant
215-
tracker = track(state)
209+
result = tracker.detectMutations()
210+
// Track before potentially not meeting the invariant
211+
tracker = track(state)
216212

217-
result.wasMutated &&
218213
invariant(
219214
!result.wasMutated,
220-
`A state mutation was detected inside a dispatch, in the path: ${(
215+
`A state mutation was detected between dispatches, in the path '${(
221216
result.path || []
222217
).join(
223218
'.'
224-
)}. Take a look at the reducer(s) handling the action ${stringify(
225-
action
226-
)}. (http://redux.js.org/docs/Troubleshooting.html#never-mutate-reducer-arguments)`
219+
)}'. This may cause incorrect behavior. (http://redux.js.org/docs/Troubleshooting.html#never-mutate-reducer-arguments)`
227220
)
221+
})
222+
223+
const dispatchedAction = next(action)
224+
225+
measureUtils.measureTime(() => {
226+
state = getState()
227+
228+
result = tracker.detectMutations()
229+
// Track before potentially not meeting the invariant
230+
tracker = track(state)
231+
232+
result.wasMutated &&
233+
invariant(
234+
!result.wasMutated,
235+
`A state mutation was detected inside a dispatch, in the path: ${(
236+
result.path || []
237+
).join(
238+
'.'
239+
)}. Take a look at the reducer(s) handling the action ${stringify(
240+
action
241+
)}. (http://redux.js.org/docs/Troubleshooting.html#never-mutate-reducer-arguments)`
242+
)
243+
})
244+
245+
measureUtils.warnIfExceeded()
228246

229247
return dispatchedAction
230248
}

src/serializableStateInvariantMiddleware.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,4 +408,51 @@ describe('serializableStateInvariantMiddleware', () => {
408408
expect(log).toBe('')
409409
const q = 42
410410
})
411+
412+
it('Should print a warning if execution takes too long', () => {
413+
const reducer: Reducer = (state = 42, action) => {
414+
return state
415+
}
416+
417+
const serializableStateInvariantMiddleware = createSerializableStateInvariantMiddleware(
418+
{ warnAfter: 4 }
419+
)
420+
421+
const store = configureStore({
422+
reducer: {
423+
testSlice: reducer
424+
},
425+
middleware: [serializableStateInvariantMiddleware]
426+
})
427+
428+
store.dispatch({
429+
type: 'SOME_ACTION',
430+
payload: new Array(10000).fill({ value: 'more' })
431+
})
432+
expect(getLog().log).toMatch(
433+
/^SerializableStateInvariantMiddleware took \d*ms, which is more than the warning threshold of 4ms./
434+
)
435+
})
436+
437+
it('Should not print a warning if "reducer" takes too long', () => {
438+
const reducer: Reducer = (state = 42, action) => {
439+
const started = Date.now()
440+
while (Date.now() - started < 8) {}
441+
return state
442+
}
443+
444+
const serializableStateInvariantMiddleware = createSerializableStateInvariantMiddleware(
445+
{ warnAfter: 4 }
446+
)
447+
448+
const store = configureStore({
449+
reducer: {
450+
testSlice: reducer
451+
},
452+
middleware: [serializableStateInvariantMiddleware]
453+
})
454+
455+
store.dispatch({ type: 'SOME_ACTION' })
456+
expect(getLog().log).toMatch('')
457+
})
411458
})

src/serializableStateInvariantMiddleware.ts

Lines changed: 48 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import isPlainObject from './isPlainObject'
22
import { Middleware } from 'redux'
3+
import { getTimeMeasureUtils } from './utils'
34

45
/**
56
* Returns true if the passed value is "plain", i.e. a value that is either
@@ -114,6 +115,10 @@ export interface SerializableStateInvariantMiddlewareOptions {
114115
* An array of dot-separated path strings to ignore when checking for serializability, Defaults to []
115116
*/
116117
ignoredPaths?: string[]
118+
/**
119+
* Execution time warning threshold. If the middleware takes longer than `warnAfter` ms, a warning will be displayed in the console. Defaults to 32
120+
*/
121+
warnAfter?: number
117122
}
118123

119124
/**
@@ -135,56 +140,67 @@ export function createSerializableStateInvariantMiddleware(
135140
isSerializable = isPlain,
136141
getEntries,
137142
ignoredActions = [],
138-
ignoredPaths = []
143+
ignoredPaths = [],
144+
warnAfter = 32
139145
} = options
140146

141147
return storeAPI => next => action => {
142148
if (ignoredActions.length && ignoredActions.indexOf(action.type) !== -1) {
143149
return next(action)
144150
}
145151

146-
const foundActionNonSerializableValue = findNonSerializableValue(
147-
action,
148-
[],
149-
isSerializable,
150-
getEntries
152+
const measureUtils = getTimeMeasureUtils(
153+
warnAfter,
154+
'SerializableStateInvariantMiddleware'
151155
)
152-
153-
if (foundActionNonSerializableValue) {
154-
const { keyPath, value } = foundActionNonSerializableValue
155-
156-
console.error(
157-
`A non-serializable value was detected in an action, in the path: \`${keyPath}\`. Value:`,
158-
value,
159-
'\nTake a look at the logic that dispatched this action: ',
156+
measureUtils.measureTime(() => {
157+
const foundActionNonSerializableValue = findNonSerializableValue(
160158
action,
161-
'\n(See https://redux.js.org/faq/actions#why-should-type-be-a-string-or-at-least-serializable-why-should-my-action-types-be-constants)'
159+
[],
160+
isSerializable,
161+
getEntries
162162
)
163-
}
163+
164+
if (foundActionNonSerializableValue) {
165+
const { keyPath, value } = foundActionNonSerializableValue
166+
167+
console.error(
168+
`A non-serializable value was detected in an action, in the path: \`${keyPath}\`. Value:`,
169+
value,
170+
'\nTake a look at the logic that dispatched this action: ',
171+
action,
172+
'\n(See https://redux.js.org/faq/actions#why-should-type-be-a-string-or-at-least-serializable-why-should-my-action-types-be-constants)'
173+
)
174+
}
175+
})
164176

165177
const result = next(action)
166178

167-
const state = storeAPI.getState()
179+
measureUtils.measureTime(() => {
180+
const state = storeAPI.getState()
168181

169-
const foundStateNonSerializableValue = findNonSerializableValue(
170-
state,
171-
[],
172-
isSerializable,
173-
getEntries,
174-
ignoredPaths
175-
)
182+
const foundStateNonSerializableValue = findNonSerializableValue(
183+
state,
184+
[],
185+
isSerializable,
186+
getEntries,
187+
ignoredPaths
188+
)
176189

177-
if (foundStateNonSerializableValue) {
178-
const { keyPath, value } = foundStateNonSerializableValue
190+
if (foundStateNonSerializableValue) {
191+
const { keyPath, value } = foundStateNonSerializableValue
179192

180-
console.error(
181-
`A non-serializable value was detected in the state, in the path: \`${keyPath}\`. Value:`,
182-
value,
183-
`
193+
console.error(
194+
`A non-serializable value was detected in the state, in the path: \`${keyPath}\`. Value:`,
195+
value,
196+
`
184197
Take a look at the reducer(s) handling this action type: ${action.type}.
185198
(See https://redux.js.org/faq/organizing-state#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state)`
186-
)
187-
}
199+
)
200+
}
201+
})
202+
203+
measureUtils.warnIfExceeded()
188204

189205
return result
190206
}

src/utils.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export function getTimeMeasureUtils(maxDelay: number, fnName: string) {
2+
let elapsed = 0
3+
return {
4+
measureTime<T>(fn: () => T): T {
5+
const started = Date.now()
6+
try {
7+
return fn()
8+
} finally {
9+
const finished = Date.now()
10+
elapsed += finished - started
11+
}
12+
},
13+
warnIfExceeded() {
14+
if (elapsed > maxDelay) {
15+
console.warn(`${fnName} took ${elapsed}ms, which is more than the warning threshold of ${maxDelay}ms.
16+
If you are passing very large objects into your state, you might to disable the middleware as it might cause too much of a slowdown in development mode.
17+
It is disabled in production builds, so you don't need to worry about that.`)
18+
}
19+
}
20+
}
21+
}

0 commit comments

Comments
 (0)