UNPKG

6.33 kBPlain TextView Raw
1import isPlainObject from './isPlainObject'
2import type { Middleware } from 'redux'
3import { getTimeMeasureUtils } from './utils'
4
5/**
6 * Returns true if the passed value is "plain", i.e. a value that is either
7 * directly JSON-serializable (boolean, number, string, array, plain object)
8 * or `undefined`.
9 *
10 * @param val The value to check.
11 *
12 * @public
13 */
14export function isPlain(val: any) {
15 const type = typeof val
16 return (
17 type === 'undefined' ||
18 val === null ||
19 type === 'string' ||
20 type === 'boolean' ||
21 type === 'number' ||
22 Array.isArray(val) ||
23 isPlainObject(val)
24 )
25}
26
27interface NonSerializableValue {
28 keyPath: string
29 value: unknown
30}
31
32/**
33 * @public
34 */
35export function findNonSerializableValue(
36 value: unknown,
37 path: string = '',
38 isSerializable: (value: unknown) => boolean = isPlain,
39 getEntries?: (value: unknown) => [string, any][],
40 ignoredPaths: readonly string[] = []
41): NonSerializableValue | false {
42 let foundNestedSerializable: NonSerializableValue | false
43
44 if (!isSerializable(value)) {
45 return {
46 keyPath: path || '<root>',
47 value: value,
48 }
49 }
50
51 if (typeof value !== 'object' || value === null) {
52 return false
53 }
54
55 const entries = getEntries != null ? getEntries(value) : Object.entries(value)
56
57 const hasIgnoredPaths = ignoredPaths.length > 0
58
59 for (const [key, nestedValue] of entries) {
60 const nestedPath = path ? path + '.' + key : key
61
62 if (hasIgnoredPaths && ignoredPaths.indexOf(nestedPath) >= 0) {
63 continue
64 }
65
66 if (!isSerializable(nestedValue)) {
67 return {
68 keyPath: nestedPath,
69 value: nestedValue,
70 }
71 }
72
73 if (typeof nestedValue === 'object') {
74 foundNestedSerializable = findNonSerializableValue(
75 nestedValue,
76 nestedPath,
77 isSerializable,
78 getEntries,
79 ignoredPaths
80 )
81
82 if (foundNestedSerializable) {
83 return foundNestedSerializable
84 }
85 }
86 }
87
88 return false
89}
90
91/**
92 * Options for `createSerializableStateInvariantMiddleware()`.
93 *
94 * @public
95 */
96export interface SerializableStateInvariantMiddlewareOptions {
97 /**
98 * The function to check if a value is considered serializable. This
99 * function is applied recursively to every value contained in the
100 * state. Defaults to `isPlain()`.
101 */
102 isSerializable?: (value: any) => boolean
103 /**
104 * The function that will be used to retrieve entries from each
105 * value. If unspecified, `Object.entries` will be used. Defaults
106 * to `undefined`.
107 */
108 getEntries?: (value: any) => [string, any][]
109
110 /**
111 * An array of action types to ignore when checking for serializability.
112 * Defaults to []
113 */
114 ignoredActions?: string[]
115
116 /**
117 * An array of dot-separated path strings to ignore when checking
118 * for serializability, Defaults to ['meta.arg']
119 */
120 ignoredActionPaths?: string[]
121
122 /**
123 * An array of dot-separated path strings to ignore when checking
124 * for serializability, Defaults to []
125 */
126 ignoredPaths?: string[]
127 /**
128 * Execution time warning threshold. If the middleware takes longer
129 * than `warnAfter` ms, a warning will be displayed in the console.
130 * Defaults to 32ms.
131 */
132 warnAfter?: number
133
134 /**
135 * Opt out of checking state, but continue checking actions
136 */
137 ignoreState?: boolean
138}
139
140/**
141 * Creates a middleware that, after every state change, checks if the new
142 * state is serializable. If a non-serializable value is found within the
143 * state, an error is printed to the console.
144 *
145 * @param options Middleware options.
146 *
147 * @public
148 */
149export function createSerializableStateInvariantMiddleware(
150 options: SerializableStateInvariantMiddlewareOptions = {}
151): Middleware {
152 if (process.env.NODE_ENV === 'production') {
153 return () => (next) => (action) => next(action)
154 }
155 const {
156 isSerializable = isPlain,
157 getEntries,
158 ignoredActions = [],
159 ignoredActionPaths = ['meta.arg', 'meta.baseQueryMeta'],
160 ignoredPaths = [],
161 warnAfter = 32,
162 ignoreState = false,
163 } = options
164
165 return (storeAPI) => (next) => (action) => {
166 if (ignoredActions.length && ignoredActions.indexOf(action.type) !== -1) {
167 return next(action)
168 }
169
170 const measureUtils = getTimeMeasureUtils(
171 warnAfter,
172 'SerializableStateInvariantMiddleware'
173 )
174 measureUtils.measureTime(() => {
175 const foundActionNonSerializableValue = findNonSerializableValue(
176 action,
177 '',
178 isSerializable,
179 getEntries,
180 ignoredActionPaths
181 )
182
183 if (foundActionNonSerializableValue) {
184 const { keyPath, value } = foundActionNonSerializableValue
185
186 console.error(
187 `A non-serializable value was detected in an action, in the path: \`${keyPath}\`. Value:`,
188 value,
189 '\nTake a look at the logic that dispatched this action: ',
190 action,
191 '\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)',
192 '\n(To allow non-serializable values see: https://redux-toolkit.js.org/usage/usage-guide#working-with-non-serializable-data)'
193 )
194 }
195 })
196
197 const result = next(action)
198
199 if (!ignoreState) {
200 measureUtils.measureTime(() => {
201 const state = storeAPI.getState()
202
203 const foundStateNonSerializableValue = findNonSerializableValue(
204 state,
205 '',
206 isSerializable,
207 getEntries,
208 ignoredPaths
209 )
210
211 if (foundStateNonSerializableValue) {
212 const { keyPath, value } = foundStateNonSerializableValue
213
214 console.error(
215 `A non-serializable value was detected in the state, in the path: \`${keyPath}\`. Value:`,
216 value,
217 `
218Take a look at the reducer(s) handling this action type: ${action.type}.
219(See https://redux.js.org/faq/organizing-state#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state)`
220 )
221 }
222 })
223
224 measureUtils.warnIfExceeded()
225 }
226
227 return result
228 }
229}