UNPKG

7.46 kBPlain TextView Raw
1import type { Middleware } from 'redux'
2import { getTimeMeasureUtils } from './utils'
3
4type EntryProcessor = (key: string, value: any) => any
5
6/**
7 * The default `isImmutable` function.
8 *
9 * @public
10 */
11export function isImmutableDefault(value: unknown): boolean {
12 return typeof value !== 'object' || value == null || Object.isFrozen(value)
13}
14
15export function trackForMutations(
16 isImmutable: IsImmutableFunc,
17 ignorePaths: IgnorePaths | undefined,
18 obj: any,
19) {
20 const trackedProperties = trackProperties(isImmutable, ignorePaths, obj)
21 return {
22 detectMutations() {
23 return detectMutations(isImmutable, ignorePaths, trackedProperties, obj)
24 },
25 }
26}
27
28interface TrackedProperty {
29 value: any
30 children: Record<string, any>
31}
32
33function trackProperties(
34 isImmutable: IsImmutableFunc,
35 ignorePaths: IgnorePaths = [],
36 obj: Record<string, any>,
37 path: string = '',
38 checkedObjects: Set<Record<string, any>> = new Set(),
39) {
40 const tracked: Partial<TrackedProperty> = { value: obj }
41
42 if (!isImmutable(obj) && !checkedObjects.has(obj)) {
43 checkedObjects.add(obj)
44 tracked.children = {}
45
46 for (const key in obj) {
47 const childPath = path ? path + '.' + key : key
48 if (ignorePaths.length && ignorePaths.indexOf(childPath) !== -1) {
49 continue
50 }
51
52 tracked.children[key] = trackProperties(
53 isImmutable,
54 ignorePaths,
55 obj[key],
56 childPath,
57 )
58 }
59 }
60 return tracked as TrackedProperty
61}
62
63type IgnorePaths = readonly (string | RegExp)[]
64
65function detectMutations(
66 isImmutable: IsImmutableFunc,
67 ignoredPaths: IgnorePaths = [],
68 trackedProperty: TrackedProperty,
69 obj: any,
70 sameParentRef: boolean = false,
71 path: string = '',
72): { wasMutated: boolean; path?: string } {
73 const prevObj = trackedProperty ? trackedProperty.value : undefined
74
75 const sameRef = prevObj === obj
76
77 if (sameParentRef && !sameRef && !Number.isNaN(obj)) {
78 return { wasMutated: true, path }
79 }
80
81 if (isImmutable(prevObj) || isImmutable(obj)) {
82 return { wasMutated: false }
83 }
84
85 // Gather all keys from prev (tracked) and after objs
86 const keysToDetect: Record<string, boolean> = {}
87 for (let key in trackedProperty.children) {
88 keysToDetect[key] = true
89 }
90 for (let key in obj) {
91 keysToDetect[key] = true
92 }
93
94 const hasIgnoredPaths = ignoredPaths.length > 0
95
96 for (let key in keysToDetect) {
97 const nestedPath = path ? path + '.' + key : key
98
99 if (hasIgnoredPaths) {
100 const hasMatches = ignoredPaths.some((ignored) => {
101 if (ignored instanceof RegExp) {
102 return ignored.test(nestedPath)
103 }
104 return nestedPath === ignored
105 })
106 if (hasMatches) {
107 continue
108 }
109 }
110
111 const result = detectMutations(
112 isImmutable,
113 ignoredPaths,
114 trackedProperty.children[key],
115 obj[key],
116 sameRef,
117 nestedPath,
118 )
119
120 if (result.wasMutated) {
121 return result
122 }
123 }
124 return { wasMutated: false }
125}
126
127type IsImmutableFunc = (value: any) => boolean
128
129/**
130 * Options for `createImmutableStateInvariantMiddleware()`.
131 *
132 * @public
133 */
134export interface ImmutableStateInvariantMiddlewareOptions {
135 /**
136 Callback function to check if a value is considered to be immutable.
137 This function is applied recursively to every value contained in the state.
138 The default implementation will return true for primitive types
139 (like numbers, strings, booleans, null and undefined).
140 */
141 isImmutable?: IsImmutableFunc
142 /**
143 An array of dot-separated path strings that match named nodes from
144 the root state to ignore when checking for immutability.
145 Defaults to undefined
146 */
147 ignoredPaths?: IgnorePaths
148 /** Print a warning if checks take longer than N ms. Default: 32ms */
149 warnAfter?: number
150}
151
152/**
153 * Creates a middleware that checks whether any state was mutated in between
154 * dispatches or during a dispatch. If any mutations are detected, an error is
155 * thrown.
156 *
157 * @param options Middleware options.
158 *
159 * @public
160 */
161export function createImmutableStateInvariantMiddleware(
162 options: ImmutableStateInvariantMiddlewareOptions = {},
163): Middleware {
164 if (process.env.NODE_ENV === 'production') {
165 return () => (next) => (action) => next(action)
166 } else {
167 function stringify(
168 obj: any,
169 serializer?: EntryProcessor,
170 indent?: string | number,
171 decycler?: EntryProcessor,
172 ): string {
173 return JSON.stringify(obj, getSerialize(serializer, decycler), indent)
174 }
175
176 function getSerialize(
177 serializer?: EntryProcessor,
178 decycler?: EntryProcessor,
179 ): EntryProcessor {
180 let stack: any[] = [],
181 keys: any[] = []
182
183 if (!decycler)
184 decycler = function (_: string, value: any) {
185 if (stack[0] === value) return '[Circular ~]'
186 return (
187 '[Circular ~.' + keys.slice(0, stack.indexOf(value)).join('.') + ']'
188 )
189 }
190
191 return function (this: any, key: string, value: any) {
192 if (stack.length > 0) {
193 var thisPos = stack.indexOf(this)
194 ~thisPos ? stack.splice(thisPos + 1) : stack.push(this)
195 ~thisPos ? keys.splice(thisPos, Infinity, key) : keys.push(key)
196 if (~stack.indexOf(value)) value = decycler!.call(this, key, value)
197 } else stack.push(value)
198
199 return serializer == null ? value : serializer.call(this, key, value)
200 }
201 }
202
203 let {
204 isImmutable = isImmutableDefault,
205 ignoredPaths,
206 warnAfter = 32,
207 } = options
208
209 const track = trackForMutations.bind(null, isImmutable, ignoredPaths)
210
211 return ({ getState }) => {
212 let state = getState()
213 let tracker = track(state)
214
215 let result
216 return (next) => (action) => {
217 const measureUtils = getTimeMeasureUtils(
218 warnAfter,
219 'ImmutableStateInvariantMiddleware',
220 )
221
222 measureUtils.measureTime(() => {
223 state = getState()
224
225 result = tracker.detectMutations()
226 // Track before potentially not meeting the invariant
227 tracker = track(state)
228
229 if (result.wasMutated) {
230 throw new Error(
231 `A state mutation was detected between dispatches, in the path '${
232 result.path || ''
233 }'. This may cause incorrect behavior. (https://redux.js.org/style-guide/style-guide#do-not-mutate-state)`,
234 )
235 }
236 })
237
238 const dispatchedAction = next(action)
239
240 measureUtils.measureTime(() => {
241 state = getState()
242
243 result = tracker.detectMutations()
244 // Track before potentially not meeting the invariant
245 tracker = track(state)
246
247 if (result.wasMutated) {
248 throw new Error(
249 `A state mutation was detected inside a dispatch, in the path: ${
250 result.path || ''
251 }. Take a look at the reducer(s) handling the action ${stringify(
252 action,
253 )}. (https://redux.js.org/style-guide/style-guide#do-not-mutate-state)`,
254 )
255 }
256 })
257
258 measureUtils.warnIfExceeded()
259
260 return dispatchedAction
261 }
262 }
263 }
264}