1 | import isPlainObject from './isPlainObject'
|
2 | import type { Middleware } from 'redux'
|
3 | import { getTimeMeasureUtils } from './utils'
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 | export 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 |
|
26 | interface NonSerializableValue {
|
27 | keyPath: string
|
28 | value: unknown
|
29 | }
|
30 |
|
31 | type IgnorePaths = readonly (string | RegExp)[]
|
32 |
|
33 |
|
34 |
|
35 |
|
36 | export 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 |
|
106 | export 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 |
|
120 |
|
121 |
|
122 |
|
123 | export interface SerializableStateInvariantMiddlewareOptions {
|
124 | |
125 |
|
126 |
|
127 |
|
128 |
|
129 | isSerializable?: (value: any) => boolean
|
130 | |
131 |
|
132 |
|
133 |
|
134 |
|
135 | getEntries?: (value: any) => [string, any][]
|
136 |
|
137 | |
138 |
|
139 |
|
140 |
|
141 | ignoredActions?: string[]
|
142 |
|
143 | |
144 |
|
145 |
|
146 |
|
147 |
|
148 | ignoredActionPaths?: (string | RegExp)[]
|
149 |
|
150 | |
151 |
|
152 |
|
153 |
|
154 | ignoredPaths?: (string | RegExp)[]
|
155 | |
156 |
|
157 |
|
158 |
|
159 |
|
160 | warnAfter?: number
|
161 |
|
162 | |
163 |
|
164 |
|
165 | ignoreState?: boolean
|
166 |
|
167 | |
168 |
|
169 |
|
170 | ignoreActions?: boolean
|
171 |
|
172 | |
173 |
|
174 |
|
175 |
|
176 | disableCache?: boolean
|
177 | }
|
178 |
|
179 |
|
180 |
|
181 |
|
182 |
|
183 |
|
184 |
|
185 |
|
186 |
|
187 |
|
188 | export 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 | `
|
266 | Take 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 | }
|