Skip to content

Allow consumer to augment middleware to tolerate certain structures (e.g. Immutable) #141

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

Merged
merged 4 commits into from
Jul 6, 2019
Merged
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
18 changes: 13 additions & 5 deletions docs/api/otherExports.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,28 @@ Redux Starter Kit exports some of its internal utilities, and re-exports additio

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

Accepts an options object with an `isSerializable` parameter, which will be used
to determine if a value is considered serializable or not. If not provided, this
defaults to `isPlain`.
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.

Example:

```js
import { Iterable } from 'immutable';
import {
configureStore,
createSerializableStateInvariantMiddleware
createSerializableStateInvariantMiddleware,
isPlain
} from 'redux-starter-kit'

// Augment middleware to consider Immutable.JS iterables serializable
const isSerializable = (value) =>
Iterable.isIterable(value) || isPlain(value)

const getEntries = (value) =>
Iterable.isIterable(value) ? value.entries() : Object.entries(value)

const serializableMiddleware = createSerializableStateInvariantMiddleware({
isSerializable: () => true // all values will be accepted
isSerializable,
getEntries,
})

const store = configureStore({
Expand Down
123 changes: 122 additions & 1 deletion src/serializableStateInvariantMiddleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { configureStore } from './configureStore'

import {
createSerializableStateInvariantMiddleware,
findNonSerializableValue
findNonSerializableValue,
isPlain,
} from './serializableStateInvariantMiddleware'

describe('findNonSerializableValue', () => {
Expand Down Expand Up @@ -159,6 +160,126 @@ describe('serializableStateInvariantMiddleware', () => {
expect(actionType).toBe(ACTION_TYPE)
})

describe('consumer tolerated structures', () => {
const nonSerializableValue = new Map();

const nestedSerializableObjectWithBadValue = {
isSerializable: true,
entries: (): [string, any][] =>
[
['good-string', 'Good!'],
['good-number', 1337],
['bad-map-instance', nonSerializableValue],
],
};

const serializableObject = {
isSerializable: true,
entries: (): [string, any][] =>
[
['first', 1],
['second', 'B!'],
['third', nestedSerializableObjectWithBadValue]
],
};

it('Should log an error when a non-serializable value is nested in state', () => {
const ACTION_TYPE = 'TEST_ACTION'

const initialState = {
a: 0
}

const reducer: Reducer = (state = initialState, action) => {
switch (action.type) {
case ACTION_TYPE: {
return {
a: serializableObject
}
}
default:
return state
}
}

// use default options
const serializableStateInvariantMiddleware = createSerializableStateInvariantMiddleware()

const store = configureStore({
reducer: {
testSlice: reducer
},
middleware: [serializableStateInvariantMiddleware]
})

store.dispatch({ type: ACTION_TYPE })


expect(console.error).toHaveBeenCalled()

const [
message,
keyPath,
value,
actionType
] = (console.error as jest.Mock).mock.calls[0]

// since default options are used, the `entries` function in `serializableObject` will cause the error
expect(message).toContain('detected in the state, in the path: `%s`')
expect(keyPath).toBe('testSlice.a.entries')
expect(value).toBe(serializableObject.entries)
expect(actionType).toBe(ACTION_TYPE)
})

it('Should use consumer supplied isSerializable and getEntries options to tolerate certain structures', () => {
const ACTION_TYPE = 'TEST_ACTION'

const initialState = {
a: 0
}

const isSerializable = (val: any): boolean => val.isSerializable || isPlain(val);
const getEntries = (val: any): [string, any][] => val.isSerializable ? val.entries() : Object.entries(val);

const reducer: Reducer = (state = initialState, action) => {
switch (action.type) {
case ACTION_TYPE: {
return {
a: serializableObject
}
}
default:
return state
}
}

const serializableStateInvariantMiddleware = createSerializableStateInvariantMiddleware({ isSerializable, getEntries })

const store = configureStore({
reducer: {
testSlice: reducer
},
middleware: [serializableStateInvariantMiddleware]
})

store.dispatch({ type: ACTION_TYPE })

expect(console.error).toHaveBeenCalled()

const [
message,
keyPath,
value,
actionType
] = (console.error as jest.Mock).mock.calls[0]

// error reported is from a nested class instance, rather than the `entries` function `serializableObject`
expect(message).toContain('detected in the state, in the path: `%s`')
expect(keyPath).toBe('testSlice.a.third.bad-map-instance')
expect(value).toBe(nonSerializableValue)
expect(actionType).toBe(ACTION_TYPE)
})
});
it('Should use the supplied isSerializable function to determine serializability', () => {
const ACTION_TYPE = 'TEST_ACTION'

Expand Down
29 changes: 20 additions & 9 deletions src/serializableStateInvariantMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ interface NonSerializableValue {
export function findNonSerializableValue(
value: unknown,
path: ReadonlyArray<string> = [],
isSerializable: (value: unknown) => boolean = isPlain
isSerializable: (value: unknown) => boolean = isPlain,
getEntries?: (value: unknown) => [string, any][]
): NonSerializableValue | false {
let foundNestedSerializable: NonSerializableValue | false

Expand All @@ -55,9 +56,10 @@ export function findNonSerializableValue(
return false
}

for (const property of Object.keys(value)) {
const entries = getEntries != null ? getEntries(value) : Object.entries(value);

for (const [property, nestedValue] of entries) {
const nestedPath = path.concat(property)
const nestedValue: unknown = (value as any)[property]

if (!isSerializable(nestedValue)) {
return {
Expand All @@ -70,7 +72,8 @@ export function findNonSerializableValue(
foundNestedSerializable = findNonSerializableValue(
nestedValue,
nestedPath,
isSerializable
isSerializable,
getEntries
)

if (foundNestedSerializable) {
Expand All @@ -91,7 +94,13 @@ export interface SerializableStateInvariantMiddlewareOptions {
* function is applied recursively to every value contained in the
* state. Defaults to `isPlain()`.
*/
isSerializable?: (value: any) => boolean
isSerializable?: (value: any) => boolean,
/**
* The function that will be used to retrieve entries from each
* value. If unspecified, `Object.entries` will be used. Defaults
* to `undefined`.
*/
getEntries?: (value: any) => [string, any][],
}

/**
Expand All @@ -104,13 +113,14 @@ export interface SerializableStateInvariantMiddlewareOptions {
export function createSerializableStateInvariantMiddleware(
options: SerializableStateInvariantMiddlewareOptions = {}
): Middleware {
const { isSerializable = isPlain } = options
const { isSerializable = isPlain, getEntries } = options

return storeAPI => next => action => {
const foundActionNonSerializableValue = findNonSerializableValue(
action,
[],
isSerializable
isSerializable,
getEntries,
)

if (foundActionNonSerializableValue) {
Expand All @@ -126,8 +136,9 @@ export function createSerializableStateInvariantMiddleware(
const foundStateNonSerializableValue = findNonSerializableValue(
state,
[],
isSerializable
)
isSerializable,
getEntries,
)

if (foundStateNonSerializableValue) {
const { keyPath, value } = foundStateNonSerializableValue
Expand Down