Skip to content

Commit 9174a6a

Browse files
kgregorymarkerikson
authored andcommitted
Allow consumer to augment middleware to tolerate certain structures (e.g. Immutable) (#141)
* Allow consumer to augment middleware using create options: - `isSerializable` function should be used for action and state - Add `getEntries` function to options that can be used to retrieve nested values - Modify findNonSerializableValue to use `getEntries` or `Object.entries` to find nested values. * Update docs * immutable import in example
1 parent 7b3c0ac commit 9174a6a

File tree

3 files changed

+155
-15
lines changed

3 files changed

+155
-15
lines changed

docs/api/otherExports.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,28 @@ Redux Starter Kit exports some of its internal utilities, and re-exports additio
1515

1616
Creates an instance of the `serializable-state-invariant` middleware described in [`getDefaultMiddleware`](./getDefaultMiddleware.md).
1717

18-
Accepts an options object with an `isSerializable` parameter, which will be used
19-
to determine if a value is considered serializable or not. If not provided, this
20-
defaults to `isPlain`.
18+
Accepts an options object with `isSerializable` and `getEntries` parameters. The former, `isSerializable`, will be used to determine if a value is considered serializable or not. If not provided, this defaults to `isPlain`. The latter, `getEntries`, will be used to retrieve nested values. If not provided, `Object.entries` will be used by default.
2119

2220
Example:
2321

2422
```js
23+
import { Iterable } from 'immutable';
2524
import {
2625
configureStore,
27-
createSerializableStateInvariantMiddleware
26+
createSerializableStateInvariantMiddleware,
27+
isPlain
2828
} from 'redux-starter-kit'
2929

30+
// Augment middleware to consider Immutable.JS iterables serializable
31+
const isSerializable = (value) =>
32+
Iterable.isIterable(value) || isPlain(value)
33+
34+
const getEntries = (value) =>
35+
Iterable.isIterable(value) ? value.entries() : Object.entries(value)
36+
3037
const serializableMiddleware = createSerializableStateInvariantMiddleware({
31-
isSerializable: () => true // all values will be accepted
38+
isSerializable,
39+
getEntries,
3240
})
3341

3442
const store = configureStore({

src/serializableStateInvariantMiddleware.test.ts

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { configureStore } from './configureStore'
33

44
import {
55
createSerializableStateInvariantMiddleware,
6-
findNonSerializableValue
6+
findNonSerializableValue,
7+
isPlain,
78
} from './serializableStateInvariantMiddleware'
89

910
describe('findNonSerializableValue', () => {
@@ -159,6 +160,126 @@ describe('serializableStateInvariantMiddleware', () => {
159160
expect(actionType).toBe(ACTION_TYPE)
160161
})
161162

163+
describe('consumer tolerated structures', () => {
164+
const nonSerializableValue = new Map();
165+
166+
const nestedSerializableObjectWithBadValue = {
167+
isSerializable: true,
168+
entries: (): [string, any][] =>
169+
[
170+
['good-string', 'Good!'],
171+
['good-number', 1337],
172+
['bad-map-instance', nonSerializableValue],
173+
],
174+
};
175+
176+
const serializableObject = {
177+
isSerializable: true,
178+
entries: (): [string, any][] =>
179+
[
180+
['first', 1],
181+
['second', 'B!'],
182+
['third', nestedSerializableObjectWithBadValue]
183+
],
184+
};
185+
186+
it('Should log an error when a non-serializable value is nested in state', () => {
187+
const ACTION_TYPE = 'TEST_ACTION'
188+
189+
const initialState = {
190+
a: 0
191+
}
192+
193+
const reducer: Reducer = (state = initialState, action) => {
194+
switch (action.type) {
195+
case ACTION_TYPE: {
196+
return {
197+
a: serializableObject
198+
}
199+
}
200+
default:
201+
return state
202+
}
203+
}
204+
205+
// use default options
206+
const serializableStateInvariantMiddleware = createSerializableStateInvariantMiddleware()
207+
208+
const store = configureStore({
209+
reducer: {
210+
testSlice: reducer
211+
},
212+
middleware: [serializableStateInvariantMiddleware]
213+
})
214+
215+
store.dispatch({ type: ACTION_TYPE })
216+
217+
218+
expect(console.error).toHaveBeenCalled()
219+
220+
const [
221+
message,
222+
keyPath,
223+
value,
224+
actionType
225+
] = (console.error as jest.Mock).mock.calls[0]
226+
227+
// since default options are used, the `entries` function in `serializableObject` will cause the error
228+
expect(message).toContain('detected in the state, in the path: `%s`')
229+
expect(keyPath).toBe('testSlice.a.entries')
230+
expect(value).toBe(serializableObject.entries)
231+
expect(actionType).toBe(ACTION_TYPE)
232+
})
233+
234+
it('Should use consumer supplied isSerializable and getEntries options to tolerate certain structures', () => {
235+
const ACTION_TYPE = 'TEST_ACTION'
236+
237+
const initialState = {
238+
a: 0
239+
}
240+
241+
const isSerializable = (val: any): boolean => val.isSerializable || isPlain(val);
242+
const getEntries = (val: any): [string, any][] => val.isSerializable ? val.entries() : Object.entries(val);
243+
244+
const reducer: Reducer = (state = initialState, action) => {
245+
switch (action.type) {
246+
case ACTION_TYPE: {
247+
return {
248+
a: serializableObject
249+
}
250+
}
251+
default:
252+
return state
253+
}
254+
}
255+
256+
const serializableStateInvariantMiddleware = createSerializableStateInvariantMiddleware({ isSerializable, getEntries })
257+
258+
const store = configureStore({
259+
reducer: {
260+
testSlice: reducer
261+
},
262+
middleware: [serializableStateInvariantMiddleware]
263+
})
264+
265+
store.dispatch({ type: ACTION_TYPE })
266+
267+
expect(console.error).toHaveBeenCalled()
268+
269+
const [
270+
message,
271+
keyPath,
272+
value,
273+
actionType
274+
] = (console.error as jest.Mock).mock.calls[0]
275+
276+
// error reported is from a nested class instance, rather than the `entries` function `serializableObject`
277+
expect(message).toContain('detected in the state, in the path: `%s`')
278+
expect(keyPath).toBe('testSlice.a.third.bad-map-instance')
279+
expect(value).toBe(nonSerializableValue)
280+
expect(actionType).toBe(ACTION_TYPE)
281+
})
282+
});
162283
it('Should use the supplied isSerializable function to determine serializability', () => {
163284
const ACTION_TYPE = 'TEST_ACTION'
164285

src/serializableStateInvariantMiddleware.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ interface NonSerializableValue {
4040
export function findNonSerializableValue(
4141
value: unknown,
4242
path: ReadonlyArray<string> = [],
43-
isSerializable: (value: unknown) => boolean = isPlain
43+
isSerializable: (value: unknown) => boolean = isPlain,
44+
getEntries?: (value: unknown) => [string, any][]
4445
): NonSerializableValue | false {
4546
let foundNestedSerializable: NonSerializableValue | false
4647

@@ -55,9 +56,10 @@ export function findNonSerializableValue(
5556
return false
5657
}
5758

58-
for (const property of Object.keys(value)) {
59+
const entries = getEntries != null ? getEntries(value) : Object.entries(value);
60+
61+
for (const [property, nestedValue] of entries) {
5962
const nestedPath = path.concat(property)
60-
const nestedValue: unknown = (value as any)[property]
6163

6264
if (!isSerializable(nestedValue)) {
6365
return {
@@ -70,7 +72,8 @@ export function findNonSerializableValue(
7072
foundNestedSerializable = findNonSerializableValue(
7173
nestedValue,
7274
nestedPath,
73-
isSerializable
75+
isSerializable,
76+
getEntries
7477
)
7578

7679
if (foundNestedSerializable) {
@@ -91,7 +94,13 @@ export interface SerializableStateInvariantMiddlewareOptions {
9194
* function is applied recursively to every value contained in the
9295
* state. Defaults to `isPlain()`.
9396
*/
94-
isSerializable?: (value: any) => boolean
97+
isSerializable?: (value: any) => boolean,
98+
/**
99+
* The function that will be used to retrieve entries from each
100+
* value. If unspecified, `Object.entries` will be used. Defaults
101+
* to `undefined`.
102+
*/
103+
getEntries?: (value: any) => [string, any][],
95104
}
96105

97106
/**
@@ -104,13 +113,14 @@ export interface SerializableStateInvariantMiddlewareOptions {
104113
export function createSerializableStateInvariantMiddleware(
105114
options: SerializableStateInvariantMiddlewareOptions = {}
106115
): Middleware {
107-
const { isSerializable = isPlain } = options
116+
const { isSerializable = isPlain, getEntries } = options
108117

109118
return storeAPI => next => action => {
110119
const foundActionNonSerializableValue = findNonSerializableValue(
111120
action,
112121
[],
113-
isSerializable
122+
isSerializable,
123+
getEntries,
114124
)
115125

116126
if (foundActionNonSerializableValue) {
@@ -126,8 +136,9 @@ export function createSerializableStateInvariantMiddleware(
126136
const foundStateNonSerializableValue = findNonSerializableValue(
127137
state,
128138
[],
129-
isSerializable
130-
)
139+
isSerializable,
140+
getEntries,
141+
)
131142

132143
if (foundStateNonSerializableValue) {
133144
const { keyPath, value } = foundStateNonSerializableValue

0 commit comments

Comments
 (0)