Repository: reduxjs/redux-toolkit (ts)

Issue:
Add option to skip serializability check for state slices
The `redux-immutable-state-invariant` middleware has an option for skipping checks for selected slices of state:  https://github.com/leoasis/redux-immutable-state-invariant#api .  However, our homegrown `serializable-state-invariant` middleware only allows determining if a given value is serializable, and skipping specific actions, not skipping slices of state.

Per https://stackoverflow.com/questions/59773345/react-toolkit-and-redux-first-router , it looks like Redux-First-Router in particular tries to store thunks directly in the store. It's a bad idea, but seems like we should at least update the serializable middleware to accept a list of state slices to skip.

Since our serializable middleware was directly inspired by the immutable middleware to begin with, we should be able to look at how the immutable middleware implements this, and reuse the same approach.

Pull request description:
Add ignoredPaths option to ignore serializability check
Fix #319

Add a new option to `createSerializableStateInvariantMiddleware` called `ignoredPaths`. It accepts an array of dot-separated path strings to be ignored when checking for serializability in state.

```js
const serializableStateInvariantMiddleware = createSerializableStateInvariantMiddleware(
  {
    ignoredPaths: ["testSlice.a", "testSlice.b.c"]
  }
);
```


Patch:
diff --git a/docs/api/otherExports.md b/docs/api/otherExports.md
index 1bc5fa4a..61761298 100644
--- a/docs/api/otherExports.md
+++ b/docs/api/otherExports.md
@@ -15,7 +15,26 @@ Redux Toolkit exports some of its internal utilities, and re-exports additional
 
 Creates an instance of the `serializable-state-invariant` middleware described in [`getDefaultMiddleware`](./getDefaultMiddleware.md).
 
-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.
+Accepts a single configuration object parameter, with the following options:
+
+```ts
+function createSerializableStateInvariantMiddleware({
+  // The function to check if a value is considered serializable.
+  // This function is applied recursively to every value contained in the state.
+  // Defaults to `isPlain()`.
+  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][]
+  // An array of action types to ignore when checking for serializability.
+  // Defaults to []
+  ignoredActions?: string[]
+  // An array of dot-separated path strings to ignore when checking for serializability.
+  // Defaults to []
+  ignoredPaths?: string[]
+})
+```
 
 Example:
 
diff --git a/etc/redux-toolkit.api.md b/etc/redux-toolkit.api.md
index b3a419e5..0dbfed2e 100644
--- a/etc/redux-toolkit.api.md
+++ b/etc/redux-toolkit.api.md
@@ -130,7 +130,7 @@ export interface EnhancedStore<S = any, A extends Action = AnyAction, M extends
 }
 
 // @public (undocumented)
-export function findNonSerializableValue(value: unknown, path?: ReadonlyArray<string>, isSerializable?: (value: unknown) => boolean, getEntries?: (value: unknown) => [string, any][]): NonSerializableValue | false;
+export function findNonSerializableValue(value: unknown, path?: ReadonlyArray<string>, isSerializable?: (value: unknown) => boolean, getEntries?: (value: unknown) => [string, any][], ignoredPaths?: string[]): NonSerializableValue | false;
 
 // @public
 export function getDefaultMiddleware<S = any, O extends Partial<GetDefaultMiddlewareOptions> = {
@@ -177,6 +177,7 @@ export type PrepareAction<P> = ((...args: any[]) => {
 export interface SerializableStateInvariantMiddlewareOptions {
     getEntries?: (value: any) => [string, any][];
     ignoredActions?: string[];
+    ignoredPaths?: string[];
     isSerializable?: (value: any) => boolean;
 }
 
diff --git a/src/serializableStateInvariantMiddleware.ts b/src/serializableStateInvariantMiddleware.ts
index 514fdc94..9b5f610a 100644
--- a/src/serializableStateInvariantMiddleware.ts
+++ b/src/serializableStateInvariantMiddleware.ts
@@ -34,7 +34,8 @@ export function findNonSerializableValue(
   value: unknown,
   path: ReadonlyArray<string> = [],
   isSerializable: (value: unknown) => boolean = isPlain,
-  getEntries?: (value: unknown) => [string, any][]
+  getEntries?: (value: unknown) => [string, any][],
+  ignoredPaths: string[] = []
 ): NonSerializableValue | false {
   let foundNestedSerializable: NonSerializableValue | false
 
@@ -51,9 +52,15 @@ export function findNonSerializableValue(
 
   const entries = getEntries != null ? getEntries(value) : Object.entries(value)
 
+  const hasIgnoredPaths = ignoredPaths.length > 0
+
   for (const [property, nestedValue] of entries) {
     const nestedPath = path.concat(property)
 
+    if (hasIgnoredPaths && ignoredPaths.indexOf(nestedPath.join('.')) >= 0) {
+      continue
+    }
+
     if (!isSerializable(nestedValue)) {
       return {
         keyPath: nestedPath.join('.'),
@@ -66,7 +73,8 @@ export function findNonSerializableValue(
         nestedValue,
         nestedPath,
         isSerializable,
-        getEntries
+        getEntries,
+        ignoredPaths
       )
 
       if (foundNestedSerializable) {
@@ -101,6 +109,11 @@ export interface SerializableStateInvariantMiddlewareOptions {
    * An array of action types to ignore when checking for serializability, Defaults to []
    */
   ignoredActions?: string[]
+
+  /**
+   * An array of dot-separated path strings to ignore when checking for serializability, Defaults to []
+   */
+  ignoredPaths?: string[]
 }
 
 /**
@@ -115,7 +128,12 @@ export interface SerializableStateInvariantMiddlewareOptions {
 export function createSerializableStateInvariantMiddleware(
   options: SerializableStateInvariantMiddlewareOptions = {}
 ): Middleware {
-  const { isSerializable = isPlain, getEntries, ignoredActions = [] } = options
+  const {
+    isSerializable = isPlain,
+    getEntries,
+    ignoredActions = [],
+    ignoredPaths = []
+  } = options
 
   return storeAPI => next => action => {
     if (ignoredActions.length && ignoredActions.indexOf(action.type) !== -1) {
@@ -149,7 +167,8 @@ export function createSerializableStateInvariantMiddleware(
       state,
       [],
       isSerializable,
-      getEntries
+      getEntries,
+      ignoredPaths
     )
 
     if (foundStateNonSerializableValue) {


Test patch:
diff --git a/src/serializableStateInvariantMiddleware.test.ts b/src/serializableStateInvariantMiddleware.test.ts
index a7a79009..5044f7da 100644
--- a/src/serializableStateInvariantMiddleware.test.ts
+++ b/src/serializableStateInvariantMiddleware.test.ts
@@ -329,4 +329,60 @@ describe('serializableStateInvariantMiddleware', () => {
 
     expect(numTimesCalled).toBeGreaterThan(0)
   })
+
+  it('should not check serializability for ignored slice names', () => {
+    const ACTION_TYPE = 'TEST_ACTION'
+
+    const initialState = {
+      a: 0
+    }
+
+    const badValue = new Map()
+
+    const reducer: Reducer = (state = initialState, action) => {
+      switch (action.type) {
+        case ACTION_TYPE: {
+          return {
+            a: badValue,
+            b: {
+              c: badValue,
+              d: badValue
+            },
+            e: { f: badValue }
+          }
+        }
+        default:
+          return state
+      }
+    }
+
+    const serializableStateInvariantMiddleware = createSerializableStateInvariantMiddleware(
+      {
+        ignoredPaths: [
+          // Test for ignoring a single value
+          'testSlice.a',
+          // Test for ignoring a single nested value
+          'testSlice.b.c',
+          // Test for ignoring an object and its children
+          'testSlice.e'
+        ]
+      }
+    )
+
+    const store = configureStore({
+      reducer: {
+        testSlice: reducer
+      },
+      middleware: [serializableStateInvariantMiddleware]
+    })
+
+    store.dispatch({ type: ACTION_TYPE })
+
+    // testSlice.b.d was not covered in ignoredPaths, so will still log the error
+    expect(getLog().log).toMatchInlineSnapshot(`
+      "A non-serializable value was detected in the state, in the path: \`testSlice.b.d\`. Value: Map {} 
+      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)"
+    `)
+  })
 })