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 | type === 'undefined' ||
|
18 | val === null ||
|
19 | type === 'string' ||
|
20 | type === 'boolean' ||
|
21 | type === 'number' ||
|
22 | Array.isArray(val) ||
|
23 | isPlainObject(val)
|
24 | )
|
25 | }
|
26 |
|
27 | interface NonSerializableValue {
|
28 | keyPath: string
|
29 | value: unknown
|
30 | }
|
31 |
|
32 |
|
33 |
|
34 |
|
35 | export 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 |
|
93 |
|
94 |
|
95 |
|
96 | export interface SerializableStateInvariantMiddlewareOptions {
|
97 | |
98 |
|
99 |
|
100 |
|
101 |
|
102 | isSerializable?: (value: any) => boolean
|
103 | |
104 |
|
105 |
|
106 |
|
107 |
|
108 | getEntries?: (value: any) => [string, any][]
|
109 |
|
110 | |
111 |
|
112 |
|
113 |
|
114 | ignoredActions?: string[]
|
115 |
|
116 | |
117 |
|
118 |
|
119 |
|
120 | ignoredActionPaths?: string[]
|
121 |
|
122 | |
123 |
|
124 |
|
125 |
|
126 | ignoredPaths?: string[]
|
127 | |
128 |
|
129 |
|
130 |
|
131 |
|
132 | warnAfter?: number
|
133 |
|
134 | |
135 |
|
136 |
|
137 | ignoreState?: boolean
|
138 | }
|
139 |
|
140 |
|
141 |
|
142 |
|
143 |
|
144 |
|
145 |
|
146 |
|
147 |
|
148 |
|
149 | export 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 | `
|
218 | Take 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 | }
|