@reduxjs/toolkit
Version:
The official, opinionated, batteries-included toolset for efficient Redux development
286 lines (244 loc) • 7.74 kB
text/typescript
import type { Middleware } from 'redux'
import { isAction, isPlainObject } from 'redux'
import { getTimeMeasureUtils } from './utils'
/**
* Returns true if the passed value is "plain", i.e. a value that is either
* directly JSON-serializable (boolean, number, string, array, plain object)
* or `undefined`.
*
* @param val The value to check.
*
* @public
*/
export function isPlain(val: any) {
const type = typeof val
return (
val == null ||
type === 'string' ||
type === 'boolean' ||
type === 'number' ||
Array.isArray(val) ||
isPlainObject(val)
)
}
interface NonSerializableValue {
keyPath: string
value: unknown
}
export type IgnorePaths = readonly (string | RegExp)[]
/**
* @public
*/
export function findNonSerializableValue(
value: unknown,
path: string = '',
isSerializable: (value: unknown) => boolean = isPlain,
getEntries?: (value: unknown) => [string, any][],
ignoredPaths: IgnorePaths = [],
cache?: WeakSet<object>,
): NonSerializableValue | false {
let foundNestedSerializable: NonSerializableValue | false
if (!isSerializable(value)) {
return {
keyPath: path || '<root>',
value: value,
}
}
if (typeof value !== 'object' || value === null) {
return false
}
if (cache?.has(value)) return false
const entries = getEntries != null ? getEntries(value) : Object.entries(value)
const hasIgnoredPaths = ignoredPaths.length > 0
for (const [key, nestedValue] of entries) {
const nestedPath = path ? path + '.' + key : key
if (hasIgnoredPaths) {
const hasMatches = ignoredPaths.some((ignored) => {
if (ignored instanceof RegExp) {
return ignored.test(nestedPath)
}
return nestedPath === ignored
})
if (hasMatches) {
continue
}
}
if (!isSerializable(nestedValue)) {
return {
keyPath: nestedPath,
value: nestedValue,
}
}
if (typeof nestedValue === 'object') {
foundNestedSerializable = findNonSerializableValue(
nestedValue,
nestedPath,
isSerializable,
getEntries,
ignoredPaths,
cache,
)
if (foundNestedSerializable) {
return foundNestedSerializable
}
}
}
if (cache && isNestedFrozen(value)) cache.add(value)
return false
}
export function isNestedFrozen(value: object) {
if (!Object.isFrozen(value)) return false
for (const nestedValue of Object.values(value)) {
if (typeof nestedValue !== 'object' || nestedValue === null) continue
if (!isNestedFrozen(nestedValue)) return false
}
return true
}
/**
* Options for `createSerializableStateInvariantMiddleware()`.
*
* @public
*/
export interface SerializableStateInvariantMiddlewareOptions {
/**
* The function to check if a value is considered serializable. This
* function is applied recursively to every value contained in the
* state. Defaults to `isPlain()`.
*/
isSerializable?: (value: any) => boolean
/**
* The function that will be used to retrieve entries from each
* value. If unspecified, `Object.entries` will be used. Defaults
* to `undefined`.
*/
getEntries?: (value: any) => [string, any][]
/**
* An array of action types to ignore when checking for serializability.
* Defaults to []
*/
ignoredActions?: string[]
/**
* An array of dot-separated path strings or regular expressions to ignore
* when checking for serializability, Defaults to
* ['meta.arg', 'meta.baseQueryMeta']
*/
ignoredActionPaths?: (string | RegExp)[]
/**
* An array of dot-separated path strings or regular expressions to ignore
* when checking for serializability, Defaults to []
*/
ignoredPaths?: (string | RegExp)[]
/**
* Execution time warning threshold. If the middleware takes longer
* than `warnAfter` ms, a warning will be displayed in the console.
* Defaults to 32ms.
*/
warnAfter?: number
/**
* Opt out of checking state. When set to `true`, other state-related params will be ignored.
*/
ignoreState?: boolean
/**
* Opt out of checking actions. When set to `true`, other action-related params will be ignored.
*/
ignoreActions?: boolean
/**
* Opt out of caching the results. The cache uses a WeakSet and speeds up repeated checking processes.
* The cache is automatically disabled if no browser support for WeakSet is present.
*/
disableCache?: boolean
}
/**
* Creates a middleware that, after every state change, checks if the new
* state is serializable. If a non-serializable value is found within the
* state, an error is printed to the console.
*
* @param options Middleware options.
*
* @public
*/
export function createSerializableStateInvariantMiddleware(
options: SerializableStateInvariantMiddlewareOptions = {},
): Middleware {
if (process.env.NODE_ENV === 'production') {
return () => (next) => (action) => next(action)
} else {
const {
isSerializable = isPlain,
getEntries,
ignoredActions = [],
ignoredActionPaths = ['meta.arg', 'meta.baseQueryMeta'],
ignoredPaths = [],
warnAfter = 32,
ignoreState = false,
ignoreActions = false,
disableCache = false,
} = options
const cache: WeakSet<object> | undefined =
!disableCache && WeakSet ? new WeakSet() : undefined
return (storeAPI) => (next) => (action) => {
if (!isAction(action)) {
return next(action)
}
const result = next(action)
const measureUtils = getTimeMeasureUtils(
warnAfter,
'SerializableStateInvariantMiddleware',
)
if (
!ignoreActions &&
!(
ignoredActions.length &&
ignoredActions.indexOf(action.type as any) !== -1
)
) {
measureUtils.measureTime(() => {
const foundActionNonSerializableValue = findNonSerializableValue(
action,
'',
isSerializable,
getEntries,
ignoredActionPaths,
cache,
)
if (foundActionNonSerializableValue) {
const { keyPath, value } = foundActionNonSerializableValue
console.error(
`A non-serializable value was detected in an action, in the path: \`${keyPath}\`. Value:`,
value,
'\nTake a look at the logic that dispatched this action: ',
action,
'\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)',
'\n(To allow non-serializable values see: https://redux-toolkit.js.org/usage/usage-guide#working-with-non-serializable-data)',
)
}
})
}
if (!ignoreState) {
measureUtils.measureTime(() => {
const state = storeAPI.getState()
const foundStateNonSerializableValue = findNonSerializableValue(
state,
'',
isSerializable,
getEntries,
ignoredPaths,
cache,
)
if (foundStateNonSerializableValue) {
const { keyPath, value } = foundStateNonSerializableValue
console.error(
`A non-serializable value was detected in the state, in the path: \`${keyPath}\`. Value:`,
value,
`
Take a look at the reducer(s) handling this action type: ${action.type}.
(See https://redux.js.org/faq/organizing-state#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state)`,
)
}
})
measureUtils.warnIfExceeded()
}
return result
}
}
}