UNPKG

7.75 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 val == null ||
18 type === 'string' ||
19 type === 'boolean' ||
20 type === 'number' ||
21 Array.isArray(val) ||
22 isPlainObject(val)
23 )
24}
25
26interface NonSerializableValue {
27 keyPath: string
28 value: unknown
29}
30
31type IgnorePaths = readonly (string | RegExp)[]
32
33/**
34 * @public
35 */
36export function findNonSerializableValue(
37 value: unknown,
38 path: string = '',
39 isSerializable: (value: unknown) => boolean = isPlain,
40 getEntries?: (value: unknown) => [string, any][],
41 ignoredPaths: IgnorePaths = [],
42 cache?: WeakSet<object>
43): NonSerializableValue | false {
44 let foundNestedSerializable: NonSerializableValue | false
45
46 if (!isSerializable(value)) {
47 return {
48 keyPath: path || '<root>',
49 value: value,
50 }
51 }
52
53 if (typeof value !== 'object' || value === null) {
54 return false
55 }
56
57 if (cache?.has(value)) return false
58
59 const entries = getEntries != null ? getEntries(value) : Object.entries(value)
60
61 const hasIgnoredPaths = ignoredPaths.length > 0
62
63 for (const [key, nestedValue] of entries) {
64 const nestedPath = path ? path + '.' + key : key
65
66 if (hasIgnoredPaths) {
67 const hasMatches = ignoredPaths.some((ignored) => {
68 if (ignored instanceof RegExp) {
69 return ignored.test(nestedPath)
70 }
71 return nestedPath === ignored
72 })
73 if (hasMatches) {
74 continue
75 }
76 }
77
78 if (!isSerializable(nestedValue)) {
79 return {
80 keyPath: nestedPath,
81 value: nestedValue,
82 }
83 }
84
85 if (typeof nestedValue === 'object') {
86 foundNestedSerializable = findNonSerializableValue(
87 nestedValue,
88 nestedPath,
89 isSerializable,
90 getEntries,
91 ignoredPaths,
92 cache
93 )
94
95 if (foundNestedSerializable) {
96 return foundNestedSerializable
97 }
98 }
99 }
100
101 if (cache && isNestedFrozen(value)) cache.add(value)
102
103 return false
104}
105
106export function isNestedFrozen(value: object) {
107 if (!Object.isFrozen(value)) return false
108
109 for (const nestedValue of Object.values(value)) {
110 if (typeof nestedValue !== 'object' || nestedValue === null) continue
111
112 if (!isNestedFrozen(nestedValue)) return false
113 }
114
115 return true
116}
117
118/**
119 * Options for `createSerializableStateInvariantMiddleware()`.
120 *
121 * @public
122 */
123export interface SerializableStateInvariantMiddlewareOptions {
124 /**
125 * The function to check if a value is considered serializable. This
126 * function is applied recursively to every value contained in the
127 * state. Defaults to `isPlain()`.
128 */
129 isSerializable?: (value: any) => boolean
130 /**
131 * The function that will be used to retrieve entries from each
132 * value. If unspecified, `Object.entries` will be used. Defaults
133 * to `undefined`.
134 */
135 getEntries?: (value: any) => [string, any][]
136
137 /**
138 * An array of action types to ignore when checking for serializability.
139 * Defaults to []
140 */
141 ignoredActions?: string[]
142
143 /**
144 * An array of dot-separated path strings or regular expressions to ignore
145 * when checking for serializability, Defaults to
146 * ['meta.arg', 'meta.baseQueryMeta']
147 */
148 ignoredActionPaths?: (string | RegExp)[]
149
150 /**
151 * An array of dot-separated path strings or regular expressions to ignore
152 * when checking for serializability, Defaults to []
153 */
154 ignoredPaths?: (string | RegExp)[]
155 /**
156 * Execution time warning threshold. If the middleware takes longer
157 * than `warnAfter` ms, a warning will be displayed in the console.
158 * Defaults to 32ms.
159 */
160 warnAfter?: number
161
162 /**
163 * Opt out of checking state. When set to `true`, other state-related params will be ignored.
164 */
165 ignoreState?: boolean
166
167 /**
168 * Opt out of checking actions. When set to `true`, other action-related params will be ignored.
169 */
170 ignoreActions?: boolean
171
172 /**
173 * Opt out of caching the results. The cache uses a WeakSet and speeds up repeated checking processes.
174 * The cache is automatically disabled if no browser support for WeakSet is present.
175 */
176 disableCache?: boolean
177}
178
179/**
180 * Creates a middleware that, after every state change, checks if the new
181 * state is serializable. If a non-serializable value is found within the
182 * state, an error is printed to the console.
183 *
184 * @param options Middleware options.
185 *
186 * @public
187 */
188export function createSerializableStateInvariantMiddleware(
189 options: SerializableStateInvariantMiddlewareOptions = {}
190): Middleware {
191 if (process.env.NODE_ENV === 'production') {
192 return () => (next) => (action) => next(action)
193 }
194 const {
195 isSerializable = isPlain,
196 getEntries,
197 ignoredActions = [],
198 ignoredActionPaths = ['meta.arg', 'meta.baseQueryMeta'],
199 ignoredPaths = [],
200 warnAfter = 32,
201 ignoreState = false,
202 ignoreActions = false,
203 disableCache = false,
204 } = options
205
206 const cache: WeakSet<object> | undefined =
207 !disableCache && WeakSet ? new WeakSet() : undefined
208
209 return (storeAPI) => (next) => (action) => {
210 const result = next(action)
211
212 const measureUtils = getTimeMeasureUtils(
213 warnAfter,
214 'SerializableStateInvariantMiddleware'
215 )
216
217 if (
218 !ignoreActions &&
219 !(ignoredActions.length && ignoredActions.indexOf(action.type) !== -1)
220 ) {
221 measureUtils.measureTime(() => {
222 const foundActionNonSerializableValue = findNonSerializableValue(
223 action,
224 '',
225 isSerializable,
226 getEntries,
227 ignoredActionPaths,
228 cache
229 )
230
231 if (foundActionNonSerializableValue) {
232 const { keyPath, value } = foundActionNonSerializableValue
233
234 console.error(
235 `A non-serializable value was detected in an action, in the path: \`${keyPath}\`. Value:`,
236 value,
237 '\nTake a look at the logic that dispatched this action: ',
238 action,
239 '\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)',
240 '\n(To allow non-serializable values see: https://redux-toolkit.js.org/usage/usage-guide#working-with-non-serializable-data)'
241 )
242 }
243 })
244 }
245
246 if (!ignoreState) {
247 measureUtils.measureTime(() => {
248 const state = storeAPI.getState()
249
250 const foundStateNonSerializableValue = findNonSerializableValue(
251 state,
252 '',
253 isSerializable,
254 getEntries,
255 ignoredPaths,
256 cache
257 )
258
259 if (foundStateNonSerializableValue) {
260 const { keyPath, value } = foundStateNonSerializableValue
261
262 console.error(
263 `A non-serializable value was detected in the state, in the path: \`${keyPath}\`. Value:`,
264 value,
265 `
266Take a look at the reducer(s) handling this action type: ${action.type}.
267(See https://redux.js.org/faq/organizing-state#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state)`
268 )
269 }
270 })
271
272 measureUtils.warnIfExceeded()
273 }
274
275 return result
276 }
277}