mirror of
https://github.com/pmndrs/zustand.git
synced 2025-12-08 19:45:52 +00:00
rewrite devtools (#675)
* rewrite `devtools` * style * merge state instead of replace, fix lint * fix lint, don't use `Array.prototype.at` * fix typo * more lint fixing * feat: allow setting state from the devtools * deference dispatch in the store to use latest `api.dispatch` that gets replaced in devtools middleware
This commit is contained in:
parent
c8243e58c0
commit
58baf27476
@ -1,12 +1,18 @@
|
||||
import { GetState, PartialState, SetState, State, StoreApi } from '../vanilla'
|
||||
|
||||
const DEVTOOLS = Symbol()
|
||||
|
||||
type DevtoolsType = {
|
||||
/**
|
||||
* @deprecated along with `api.devtools`, `api.devtools.prefix` is deprecated.
|
||||
* We no longer prefix the actions/names, because the `name` option already
|
||||
* creates a separate instance of devtools for each store.
|
||||
*/
|
||||
prefix: string
|
||||
subscribe: (dispatch: any) => () => void
|
||||
unsubscribe: () => void
|
||||
send: (action: string, state: any) => void
|
||||
send: {
|
||||
(action: string | { type: unknown }, state: any): void
|
||||
(action: null, liftedState: any): void
|
||||
}
|
||||
init: (state: any) => void
|
||||
error: (payload: any) => void
|
||||
}
|
||||
@ -20,12 +26,18 @@ export type NamedSet<T extends State> = {
|
||||
>(
|
||||
partial: PartialState<T, K1, K2, K3, K4>,
|
||||
replace?: boolean,
|
||||
name?: string
|
||||
name?: string | { type: unknown }
|
||||
): void
|
||||
}
|
||||
|
||||
export type StoreApiWithDevtools<T extends State> = StoreApi<T> & {
|
||||
setState: NamedSet<T>
|
||||
/**
|
||||
* @deprecated `devtools` property on the store is deprecated
|
||||
* it will be removed in the next major.
|
||||
* You shouldn't interact with the extension directly. But in case you still want to
|
||||
* you can patch `window.__REDUX_DEVTOOLS_EXTENSION__` directly
|
||||
*/
|
||||
devtools?: DevtoolsType
|
||||
}
|
||||
|
||||
@ -41,6 +53,7 @@ export const devtools =
|
||||
| string
|
||||
| {
|
||||
name?: string
|
||||
anonymousActionType?: string
|
||||
serialize?: {
|
||||
options:
|
||||
| boolean
|
||||
@ -61,109 +74,204 @@ export const devtools =
|
||||
(
|
||||
set: CustomSetState,
|
||||
get: CustomGetState,
|
||||
api: CustomStoreApi & StoreApiWithDevtools<S> & { dispatch?: unknown }
|
||||
api: CustomStoreApi &
|
||||
StoreApiWithDevtools<S> & {
|
||||
dispatch?: unknown
|
||||
dispatchFromDevtools?: boolean
|
||||
}
|
||||
): S => {
|
||||
let extension
|
||||
try {
|
||||
extension =
|
||||
(window as any).__REDUX_DEVTOOLS_EXTENSION__ ||
|
||||
(window as any).top.__REDUX_DEVTOOLS_EXTENSION__
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
const devtoolsOptions =
|
||||
options === undefined
|
||||
? { name: undefined, anonymousActionType: undefined }
|
||||
: typeof options === 'string'
|
||||
? { name: options }
|
||||
: options
|
||||
|
||||
if (!extension) {
|
||||
const extensionConnector =
|
||||
(window as any).__REDUX_DEVTOOLS_EXTENSION__ ??
|
||||
(window as any).top.__REDUX_DEVTOOLS_EXTENSION__
|
||||
|
||||
if (!extensionConnector) {
|
||||
if (
|
||||
process.env.NODE_ENV === 'development' &&
|
||||
typeof window !== 'undefined'
|
||||
) {
|
||||
console.warn('Please install/enable Redux devtools extension')
|
||||
console.warn(
|
||||
'[zustand devtools middleware] Please install/enable Redux devtools extension'
|
||||
)
|
||||
}
|
||||
delete api.devtools
|
||||
|
||||
return fn(set, get, api)
|
||||
}
|
||||
const namedSet: NamedSet<S> = (state, replace, name) => {
|
||||
set(state, replace)
|
||||
if (!api.dispatch && api.devtools) {
|
||||
api.devtools.send(api.devtools.prefix + (name || 'action'), get())
|
||||
}
|
||||
}
|
||||
api.setState = namedSet
|
||||
const initialState = fn(namedSet, get, api)
|
||||
if (!api.devtools) {
|
||||
const savedSetState = api.setState
|
||||
api.setState = <
|
||||
K1 extends keyof S = keyof S,
|
||||
K2 extends keyof S = K1,
|
||||
K3 extends keyof S = K2,
|
||||
K4 extends keyof S = K3
|
||||
>(
|
||||
state: PartialState<S, K1, K2, K3, K4>,
|
||||
replace?: boolean
|
||||
) => {
|
||||
const newState = api.getState()
|
||||
if (state !== newState) {
|
||||
savedSetState(state, replace)
|
||||
if (state !== (newState as any)[DEVTOOLS] && api.devtools) {
|
||||
api.devtools.send(api.devtools.prefix + 'setState', api.getState())
|
||||
}
|
||||
|
||||
let extension = Object.create(extensionConnector.connect(devtoolsOptions))
|
||||
// We're using `Object.defineProperty` to set `prefix`, so if extensionConnector.connect
|
||||
// returns the same reference we'd get cannot redefine property prefix error
|
||||
// hence we `Object.create` to make a new reference
|
||||
|
||||
let didWarnAboutDevtools = false
|
||||
Object.defineProperty(api, 'devtools', {
|
||||
get: () => {
|
||||
if (!didWarnAboutDevtools) {
|
||||
console.warn(
|
||||
'[zustand devtools middleware] `devtools` property on the store is deprecated ' +
|
||||
'it will be removed in the next major.\n' +
|
||||
"You shouldn't interact with the extension directly. But in case you still want to " +
|
||||
'you can patch `window.__REDUX_DEVTOOLS_EXTENSION__` directly'
|
||||
)
|
||||
didWarnAboutDevtools = true
|
||||
}
|
||||
}
|
||||
options = typeof options === 'string' ? { name: options } : options
|
||||
const connection = (api.devtools = extension.connect({ ...options }))
|
||||
connection.prefix = options?.name ? `${options.name} > ` : ''
|
||||
connection.subscribe((message: any) => {
|
||||
if (message.type === 'ACTION' && message.payload) {
|
||||
try {
|
||||
api.setState(JSON.parse(message.payload))
|
||||
} catch (e) {
|
||||
console.error(
|
||||
'please dispatch a serializable value that JSON.parse() support\n',
|
||||
e
|
||||
)
|
||||
}
|
||||
} else if (message.type === 'DISPATCH' && message.state) {
|
||||
const jumpState =
|
||||
message.payload.type === 'JUMP_TO_ACTION' ||
|
||||
message.payload.type === 'JUMP_TO_STATE'
|
||||
const newState = api.getState()
|
||||
;(newState as any)[DEVTOOLS] = JSON.parse(message.state)
|
||||
return extension
|
||||
},
|
||||
set: (value) => {
|
||||
if (!didWarnAboutDevtools) {
|
||||
console.warn(
|
||||
'[zustand devtools middleware] `api.devtools` is deprecated, ' +
|
||||
'it will be removed in the next major.\n' +
|
||||
"You shouldn't interact with the extension directly. But in case you still want to " +
|
||||
'you can patch `window.__REDUX_DEVTOOLS_EXTENSION__` directly'
|
||||
)
|
||||
didWarnAboutDevtools = true
|
||||
}
|
||||
extension = value
|
||||
},
|
||||
})
|
||||
|
||||
if (!api.dispatch && !jumpState) {
|
||||
api.setState(newState)
|
||||
} else if (jumpState) {
|
||||
api.setState((newState as any)[DEVTOOLS])
|
||||
} else {
|
||||
savedSetState(newState)
|
||||
}
|
||||
} else if (
|
||||
message.type === 'DISPATCH' &&
|
||||
message.payload?.type === 'COMMIT'
|
||||
) {
|
||||
connection.init(api.getState())
|
||||
} else if (
|
||||
message.type === 'DISPATCH' &&
|
||||
message.payload?.type === 'IMPORT_STATE'
|
||||
) {
|
||||
const actions = message.payload.nextLiftedState?.actionsById
|
||||
const computedStates =
|
||||
message.payload.nextLiftedState?.computedStates || []
|
||||
let didWarnAboutPrefix = false
|
||||
Object.defineProperty(extension, 'prefix', {
|
||||
get: () => {
|
||||
if (!didWarnAboutPrefix) {
|
||||
console.warn(
|
||||
'[zustand devtools middleware] along with `api.devtools`, `api.devtools.prefix` is deprecated.\n' +
|
||||
'We no longer prefix the actions/names' +
|
||||
devtoolsOptions.name ===
|
||||
undefined
|
||||
? ', pass the `name` option to create a separate instance of devtools for each store.'
|
||||
: ', because the `name` option already creates a separate instance of devtools for each store.'
|
||||
)
|
||||
didWarnAboutPrefix = true
|
||||
}
|
||||
return ''
|
||||
},
|
||||
set: () => {
|
||||
if (!didWarnAboutPrefix) {
|
||||
console.warn(
|
||||
'[zustand devtools middleware] along with `api.devtools`, `api.devtools.prefix` is deprecated.\n' +
|
||||
'We no longer prefix the actions/names' +
|
||||
devtoolsOptions.name ===
|
||||
undefined
|
||||
? ', pass the `name` option to create a separate instance of devtools for each store.'
|
||||
: ', because the `name` option already creates a separate instance of devtools for each store.'
|
||||
)
|
||||
didWarnAboutPrefix = true
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
computedStates.forEach(
|
||||
({ state }: { state: PartialState<S> }, index: number) => {
|
||||
const action = actions[index] || 'No action found'
|
||||
let isRecording = true
|
||||
;(api.setState as NamedSet<S>) = (state, replace, nameOrAction) => {
|
||||
set(state, replace)
|
||||
if (!isRecording) return
|
||||
extension.send(
|
||||
nameOrAction === undefined
|
||||
? { type: devtoolsOptions.anonymousActionType ?? 'anonymous' }
|
||||
: typeof nameOrAction === 'string'
|
||||
? { type: nameOrAction }
|
||||
: nameOrAction,
|
||||
get()
|
||||
)
|
||||
}
|
||||
const setStateFromDevtools: SetState<S> = (...a) => {
|
||||
isRecording = false
|
||||
set(...a)
|
||||
isRecording = true
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
connection.init(state)
|
||||
} else {
|
||||
savedSetState(state)
|
||||
connection.send(action, api.getState())
|
||||
const initialState = fn(api.setState, get, api)
|
||||
extension.init(initialState)
|
||||
|
||||
extension.subscribe((message: any) => {
|
||||
switch (message.type) {
|
||||
case 'ACTION':
|
||||
return parseJsonThen<{ type: unknown; state?: PartialState<S> }>(
|
||||
message.payload,
|
||||
(action) => {
|
||||
if (action.type === '__setState') {
|
||||
setStateFromDevtools(action.state as PartialState<S>)
|
||||
return
|
||||
}
|
||||
|
||||
if (!api.dispatchFromDevtools) return
|
||||
if (typeof api.dispatch !== 'function') return
|
||||
;(api.dispatch as any)(action)
|
||||
}
|
||||
)
|
||||
|
||||
case 'DISPATCH':
|
||||
switch (message.payload.type) {
|
||||
case 'RESET':
|
||||
setStateFromDevtools(initialState)
|
||||
return extension.init(api.getState())
|
||||
|
||||
case 'COMMIT':
|
||||
return extension.init(api.getState())
|
||||
|
||||
case 'ROLLBACK':
|
||||
return parseJsonThen<S>(message.state, (state) => {
|
||||
setStateFromDevtools(state)
|
||||
extension.init(api.getState())
|
||||
})
|
||||
|
||||
case 'JUMP_TO_STATE':
|
||||
case 'JUMP_TO_ACTION':
|
||||
return parseJsonThen<S>(message.state, (state) => {
|
||||
setStateFromDevtools(state)
|
||||
})
|
||||
|
||||
case 'IMPORT_STATE': {
|
||||
const { nextLiftedState } = message.payload
|
||||
const lastComputedState =
|
||||
nextLiftedState.computedStates.slice(-1)[0]?.state
|
||||
if (!lastComputedState) return
|
||||
setStateFromDevtools(lastComputedState)
|
||||
extension.send(null, nextLiftedState)
|
||||
return
|
||||
}
|
||||
|
||||
case 'PAUSE_RECORDING':
|
||||
return (isRecording = !isRecording)
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
if (api.dispatchFromDevtools && typeof api.dispatch === 'function') {
|
||||
let didWarnAboutReservedActionType = false
|
||||
const originalDispatch = api.dispatch
|
||||
api.dispatch = (...a: any[]) => {
|
||||
if (a[0].type === '__setState' && !didWarnAboutReservedActionType) {
|
||||
console.warn(
|
||||
'[zustand devtools middleware] "__setState" action type is reserved ' +
|
||||
'to set state from the devtools. Avoid using it.'
|
||||
)
|
||||
didWarnAboutReservedActionType = true
|
||||
}
|
||||
})
|
||||
connection.init(initialState)
|
||||
;(originalDispatch as any)(...a)
|
||||
}
|
||||
}
|
||||
|
||||
return initialState
|
||||
}
|
||||
|
||||
const parseJsonThen = <T>(stringified: string, f: (parsed: T) => void) => {
|
||||
let parsed: T | undefined
|
||||
try {
|
||||
parsed = JSON.parse(stringified)
|
||||
} catch (e) {
|
||||
console.error(
|
||||
'[zustand devtools middleware] Could not parse the received json',
|
||||
e
|
||||
)
|
||||
}
|
||||
if (parsed !== undefined) f(parsed as T)
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { GetState, SetState, State, StoreApi } from '../vanilla'
|
||||
import { NamedSet } from './devtools'
|
||||
|
||||
type DevtoolsType = {
|
||||
prefix: string
|
||||
@ -14,6 +15,7 @@ export type StoreApiWithRedux<
|
||||
A extends { type: unknown }
|
||||
> = StoreApi<T & { dispatch: (a: A) => A }> & {
|
||||
dispatch: (a: A) => A
|
||||
dispatchFromDevtools: boolean
|
||||
}
|
||||
|
||||
export const redux =
|
||||
@ -27,11 +29,10 @@ export const redux =
|
||||
api: StoreApiWithRedux<S, A> & { devtools?: DevtoolsType }
|
||||
): S & { dispatch: (a: A) => A } => {
|
||||
api.dispatch = (action: A) => {
|
||||
set((state: S) => reducer(state, action))
|
||||
if (api.devtools) {
|
||||
api.devtools.send(api.devtools.prefix + action.type, get())
|
||||
}
|
||||
;(set as NamedSet<S>)((state: S) => reducer(state, action), false, action)
|
||||
return action
|
||||
}
|
||||
return { dispatch: api.dispatch, ...initial }
|
||||
api.dispatchFromDevtools = true
|
||||
|
||||
return { dispatch: (...a) => api.dispatch(...a), ...initial }
|
||||
}
|
||||
|
||||
480
tests/devtools.test.tsx
Normal file
480
tests/devtools.test.tsx
Normal file
@ -0,0 +1,480 @@
|
||||
import { devtools, redux } from 'zustand/middleware'
|
||||
import create from 'zustand/vanilla'
|
||||
|
||||
let extensionSubscriber: ((message: any) => void) | undefined
|
||||
const extension = {
|
||||
subscribe: jest.fn((f) => {
|
||||
extensionSubscriber = f
|
||||
return () => {}
|
||||
}),
|
||||
unsubscribe: jest.fn(),
|
||||
send: jest.fn(),
|
||||
init: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}
|
||||
const extensionConnector = { connect: jest.fn(() => extension) }
|
||||
;(window as any).__REDUX_DEVTOOLS_EXTENSION__ = extensionConnector
|
||||
|
||||
beforeEach(() => {
|
||||
extensionConnector.connect.mockClear()
|
||||
extension.subscribe.mockClear()
|
||||
extension.unsubscribe.mockClear()
|
||||
extension.send.mockClear()
|
||||
extension.init.mockClear()
|
||||
extension.error.mockClear()
|
||||
extensionSubscriber = undefined
|
||||
})
|
||||
|
||||
it('connects to the extension by passing the options and initializes', () => {
|
||||
const options = { name: 'test', foo: 'bar' }
|
||||
const initialState = { count: 0 }
|
||||
create(devtools(() => initialState, options))
|
||||
|
||||
expect(extensionConnector.connect).toHaveBeenLastCalledWith(options)
|
||||
expect(extension.init).toHaveBeenLastCalledWith(initialState)
|
||||
})
|
||||
|
||||
describe('If there is no extension installed...', () => {
|
||||
beforeAll(() => {
|
||||
;(window as any).__REDUX_DEVTOOLS_EXTENSION__ = undefined
|
||||
})
|
||||
afterAll(() => {
|
||||
;(window as any).__REDUX_DEVTOOLS_EXTENSION__ = extensionConnector
|
||||
})
|
||||
|
||||
it('does not throw', () => {
|
||||
expect(() => {
|
||||
create(devtools(() => ({ count: 0 })))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('warns in dev env', () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'development'
|
||||
const originalConsoleWarn = console.warn
|
||||
console.warn = jest.fn()
|
||||
|
||||
create(devtools(() => ({ count: 0 })))
|
||||
expect(console.warn).toHaveBeenLastCalledWith(
|
||||
'[zustand devtools middleware] Please install/enable Redux devtools extension'
|
||||
)
|
||||
|
||||
process.env.NODE_ENV = originalNodeEnv
|
||||
console.warn = originalConsoleWarn
|
||||
})
|
||||
|
||||
it('does not warn if not in dev env', () => {
|
||||
const consoleWarn = jest.spyOn(console, 'warn')
|
||||
|
||||
create(devtools(() => ({ count: 0 })))
|
||||
expect(consoleWarn).not.toBeCalled()
|
||||
|
||||
consoleWarn.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('When state changes...', () => {
|
||||
it("sends { type: setStateName ?? 'anonymous` } as the action with current state", () => {
|
||||
const api = create(
|
||||
devtools(() => ({ count: 0, foo: 'bar' }), { name: 'testOptionsName' })
|
||||
)
|
||||
api.setState({ count: 10 }, false, 'testSetStateName')
|
||||
expect(extension.send).toHaveBeenLastCalledWith(
|
||||
{ type: 'testSetStateName' },
|
||||
{ count: 10, foo: 'bar' }
|
||||
)
|
||||
api.setState({ count: 5 }, true)
|
||||
expect(extension.send).toHaveBeenLastCalledWith(
|
||||
{ type: 'anonymous' },
|
||||
{ count: 5 }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when it receives an message of type...', () => {
|
||||
describe('ACTION...', () => {
|
||||
it('does nothing', () => {
|
||||
const initialState = { count: 0 }
|
||||
const api = create(devtools(() => initialState))
|
||||
const setState = jest.spyOn(api, 'setState')
|
||||
|
||||
;(extensionSubscriber as (message: any) => void)({
|
||||
type: 'ACTION',
|
||||
payload: '{ "type": "INCREMENT" }',
|
||||
})
|
||||
|
||||
expect(api.getState()).toBe(initialState)
|
||||
expect(setState).not.toBeCalled()
|
||||
})
|
||||
|
||||
it('unless action type is __setState', () => {
|
||||
const initialState = { count: 0 }
|
||||
const api = create(devtools(() => initialState))
|
||||
|
||||
;(extensionSubscriber as (message: any) => void)({
|
||||
type: 'ACTION',
|
||||
payload: '{ "type": "__setState", "state": { "foo": "bar" } }',
|
||||
})
|
||||
|
||||
expect(api.getState()).toStrictEqual({ ...initialState, foo: 'bar' })
|
||||
})
|
||||
|
||||
it('does nothing even if there is `api.dispatch`', () => {
|
||||
const initialState = { count: 0 }
|
||||
const api = create(devtools(() => initialState))
|
||||
api.dispatch = jest.fn()
|
||||
const setState = jest.spyOn(api, 'setState')
|
||||
|
||||
;(extensionSubscriber as (message: any) => void)({
|
||||
type: 'ACTION',
|
||||
payload: '{ "type": "INCREMENT" }',
|
||||
})
|
||||
|
||||
expect(api.getState()).toBe(initialState)
|
||||
expect(setState).not.toBeCalled()
|
||||
expect(api.dispatch).not.toBeCalled()
|
||||
})
|
||||
|
||||
it('dispatches with `api.dispatch` when `api.dispatchFromDevtools` is set to true', () => {
|
||||
const initialState = { count: 0 }
|
||||
const api = create(devtools(() => initialState))
|
||||
api.dispatch = jest.fn()
|
||||
api.dispatchFromDevtools = true
|
||||
const setState = jest.spyOn(api, 'setState')
|
||||
|
||||
;(extensionSubscriber as (message: any) => void)({
|
||||
type: 'ACTION',
|
||||
payload: '{ "type": "INCREMENT" }',
|
||||
})
|
||||
|
||||
expect(api.getState()).toBe(initialState)
|
||||
expect(setState).not.toBeCalled()
|
||||
expect(api.dispatch).toHaveBeenLastCalledWith({ type: 'INCREMENT' })
|
||||
})
|
||||
|
||||
it('does not throw for unsupported payload', () => {
|
||||
const initialState = { count: 0 }
|
||||
const api = create(devtools(() => initialState))
|
||||
api.dispatch = jest.fn()
|
||||
api.dispatchFromDevtools = true
|
||||
const setState = jest.spyOn(api, 'setState')
|
||||
const originalConsoleError = console.error
|
||||
console.error = jest.fn()
|
||||
|
||||
expect(() => {
|
||||
;(extensionSubscriber as (message: any) => void)({
|
||||
type: 'ACTION',
|
||||
payload: 'this.increment()',
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
expect(console.error).toHaveBeenLastCalledWith(
|
||||
'[zustand devtools middleware] Could not parse the received json',
|
||||
(() => {
|
||||
try {
|
||||
JSON.parse('this.increment()')
|
||||
} catch (e) {
|
||||
return e
|
||||
}
|
||||
})()
|
||||
)
|
||||
|
||||
expect(() => {
|
||||
;(extensionSubscriber as (message: any) => void)({
|
||||
type: 'ACTION',
|
||||
payload: { name: 'increment', args: [] },
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
expect(console.error).toHaveBeenLastCalledWith(
|
||||
'[zustand devtools middleware] Could not parse the received json',
|
||||
(() => {
|
||||
try {
|
||||
JSON.parse({ name: 'increment', args: [] } as unknown as string)
|
||||
} catch (e) {
|
||||
return e
|
||||
}
|
||||
})()
|
||||
)
|
||||
|
||||
expect(api.getState()).toBe(initialState)
|
||||
expect(setState).not.toBeCalled()
|
||||
expect(api.dispatch).not.toBeCalled()
|
||||
|
||||
console.error = originalConsoleError
|
||||
})
|
||||
})
|
||||
|
||||
describe('DISPATCH and payload of type...', () => {
|
||||
it('RESET, it inits with initial state', () => {
|
||||
const initialState = { count: 0 }
|
||||
const api = create(devtools(() => initialState))
|
||||
api.setState({ count: 1 })
|
||||
|
||||
extension.send.mockClear()
|
||||
;(extensionSubscriber as (message: any) => void)({
|
||||
type: 'DISPATCH',
|
||||
payload: { type: 'RESET' },
|
||||
})
|
||||
|
||||
expect(api.getState()).toStrictEqual(initialState)
|
||||
expect(extension.init).toHaveBeenLastCalledWith(initialState)
|
||||
expect(extension.send).not.toBeCalled()
|
||||
})
|
||||
|
||||
it('COMMIT, it inits with current state', () => {
|
||||
const initialState = { count: 0 }
|
||||
const api = create(devtools(() => initialState))
|
||||
api.setState({ count: 2 })
|
||||
const currentState = api.getState()
|
||||
|
||||
extension.send.mockClear()
|
||||
;(extensionSubscriber as (message: any) => void)({
|
||||
type: 'DISPATCH',
|
||||
payload: { type: 'COMMIT' },
|
||||
})
|
||||
|
||||
expect(extension.init).toHaveBeenLastCalledWith(currentState)
|
||||
expect(extension.send).not.toBeCalled()
|
||||
})
|
||||
|
||||
describe('ROLLBACK...', () => {
|
||||
it('it updates state without recording and inits with `message.state`', () => {
|
||||
const initialState = { count: 0, increment: () => {} }
|
||||
const api = create(devtools(() => initialState))
|
||||
const newState = { foo: 'bar' }
|
||||
|
||||
extension.send.mockClear()
|
||||
;(extensionSubscriber as (message: any) => void)({
|
||||
type: 'DISPATCH',
|
||||
payload: { type: 'ROLLBACK' },
|
||||
state: JSON.stringify(newState),
|
||||
})
|
||||
|
||||
expect(api.getState()).toStrictEqual({ ...initialState, ...newState })
|
||||
expect(extension.init).toHaveBeenLastCalledWith({
|
||||
...initialState,
|
||||
...newState,
|
||||
})
|
||||
expect(extension.send).not.toBeCalled()
|
||||
})
|
||||
|
||||
it('does not throw for unparsable `message.state`', () => {
|
||||
const increment = () => {}
|
||||
const initialState = { count: 0, increment }
|
||||
const api = create(devtools(() => initialState))
|
||||
const originalConsoleError = console.error
|
||||
console.error = jest.fn()
|
||||
|
||||
extension.init.mockClear()
|
||||
extension.send.mockClear()
|
||||
;(extensionSubscriber as (message: any) => void)({
|
||||
type: 'DISPATCH',
|
||||
payload: { type: 'ROLLBACK' },
|
||||
state: 'foobar',
|
||||
})
|
||||
|
||||
expect(console.error).toHaveBeenLastCalledWith(
|
||||
'[zustand devtools middleware] Could not parse the received json',
|
||||
(() => {
|
||||
try {
|
||||
JSON.parse('foobar')
|
||||
} catch (e) {
|
||||
return e
|
||||
}
|
||||
})()
|
||||
)
|
||||
expect(api.getState()).toBe(initialState)
|
||||
expect(extension.init).not.toBeCalled()
|
||||
expect(extension.send).not.toBeCalled()
|
||||
|
||||
console.error = originalConsoleError
|
||||
})
|
||||
})
|
||||
|
||||
describe('JUMP_TO_STATE...', () => {
|
||||
const increment = () => {}
|
||||
it('it updates state without recording with `message.state`', () => {
|
||||
const initialState = { count: 0, increment }
|
||||
const api = create(devtools(() => initialState))
|
||||
const newState = { foo: 'bar' }
|
||||
|
||||
extension.send.mockClear()
|
||||
;(extensionSubscriber as (message: any) => void)({
|
||||
type: 'DISPATCH',
|
||||
payload: { type: 'JUMP_TO_STATE' },
|
||||
state: JSON.stringify(newState),
|
||||
})
|
||||
expect(api.getState()).toStrictEqual({ ...initialState, ...newState })
|
||||
expect(extension.send).not.toBeCalled()
|
||||
})
|
||||
|
||||
it('does not throw for unparsable `message.state`', () => {
|
||||
const initialState = { count: 0, increment: () => {} }
|
||||
const api = create(devtools(() => initialState))
|
||||
const originalConsoleError = console.error
|
||||
console.error = jest.fn()
|
||||
|
||||
extension.send.mockClear()
|
||||
;(extensionSubscriber as (message: any) => void)({
|
||||
type: 'DISPATCH',
|
||||
payload: { type: 'JUMP_TO_STATE' },
|
||||
state: 'foobar',
|
||||
})
|
||||
|
||||
expect(console.error).toHaveBeenLastCalledWith(
|
||||
'[zustand devtools middleware] Could not parse the received json',
|
||||
(() => {
|
||||
try {
|
||||
JSON.parse('foobar')
|
||||
} catch (e) {
|
||||
return e
|
||||
}
|
||||
})()
|
||||
)
|
||||
expect(api.getState()).toBe(initialState)
|
||||
expect(extension.send).not.toBeCalled()
|
||||
|
||||
console.error = originalConsoleError
|
||||
})
|
||||
})
|
||||
|
||||
describe('JUMP_TO_ACTION...', () => {
|
||||
it('it updates state without recording with `message.state`', () => {
|
||||
const initialState = { count: 0, increment: () => {} }
|
||||
const api = create(devtools(() => initialState))
|
||||
const newState = { foo: 'bar' }
|
||||
|
||||
extension.send.mockClear()
|
||||
;(extensionSubscriber as (message: any) => void)({
|
||||
type: 'DISPATCH',
|
||||
payload: { type: 'JUMP_TO_ACTION' },
|
||||
state: JSON.stringify(newState),
|
||||
})
|
||||
expect(api.getState()).toStrictEqual({ ...initialState, ...newState })
|
||||
expect(extension.send).not.toBeCalled()
|
||||
})
|
||||
|
||||
it('does not throw for unparsable `message.state`', () => {
|
||||
const increment = () => {}
|
||||
const initialState = { count: 0, increment }
|
||||
const api = create(devtools(() => initialState))
|
||||
const originalConsoleError = console.error
|
||||
console.error = jest.fn()
|
||||
|
||||
extension.send.mockClear()
|
||||
;(extensionSubscriber as (message: any) => void)({
|
||||
type: 'DISPATCH',
|
||||
payload: { type: 'JUMP_TO_ACTION' },
|
||||
state: 'foobar',
|
||||
})
|
||||
|
||||
expect(console.error).toHaveBeenLastCalledWith(
|
||||
'[zustand devtools middleware] Could not parse the received json',
|
||||
(() => {
|
||||
try {
|
||||
JSON.parse('foobar')
|
||||
} catch (e) {
|
||||
return e
|
||||
}
|
||||
})()
|
||||
)
|
||||
expect(api.getState()).toBe(initialState)
|
||||
expect(extension.send).not.toBeCalled()
|
||||
|
||||
console.error = originalConsoleError
|
||||
})
|
||||
})
|
||||
|
||||
it('IMPORT_STATE, it updates state without recording and inits the last computedState', () => {
|
||||
const initialState = { count: 0, increment: () => {} }
|
||||
const api = create(devtools(() => initialState))
|
||||
const nextLiftedState = {
|
||||
computedStates: [{ state: { count: 4 } }, { state: { count: 5 } }],
|
||||
}
|
||||
|
||||
extension.send.mockClear()
|
||||
;(extensionSubscriber as (message: any) => void)({
|
||||
type: 'DISPATCH',
|
||||
payload: {
|
||||
type: 'IMPORT_STATE',
|
||||
nextLiftedState,
|
||||
},
|
||||
})
|
||||
expect(api.getState()).toStrictEqual({
|
||||
...initialState,
|
||||
...nextLiftedState.computedStates.slice(-1)[0]?.state,
|
||||
})
|
||||
expect(extension.send).toHaveBeenLastCalledWith(null, nextLiftedState)
|
||||
})
|
||||
|
||||
it('PAUSE_RECORDING, it toggles the sending of actions', () => {
|
||||
const api = create(devtools(() => ({ count: 0 })))
|
||||
|
||||
api.setState({ count: 1 }, false, 'increment')
|
||||
expect(extension.send).toHaveBeenLastCalledWith(
|
||||
{ type: 'increment' },
|
||||
{ count: 1 }
|
||||
)
|
||||
;(extensionSubscriber as (message: any) => void)({
|
||||
type: 'DISPATCH',
|
||||
payload: { type: 'PAUSE_RECORDING' },
|
||||
})
|
||||
|
||||
api.setState({ count: 2 }, false, 'increment')
|
||||
expect(extension.send).toHaveBeenLastCalledWith(
|
||||
{ type: 'increment' },
|
||||
{ count: 1 }
|
||||
)
|
||||
;(extensionSubscriber as (message: any) => void)({
|
||||
type: 'DISPATCH',
|
||||
payload: { type: 'PAUSE_RECORDING' },
|
||||
})
|
||||
|
||||
api.setState({ count: 3 }, false, 'increment')
|
||||
expect(extension.send).toHaveBeenLastCalledWith(
|
||||
{ type: 'increment' },
|
||||
{ count: 3 }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('works with redux middleware', () => {
|
||||
const api = create(
|
||||
devtools(
|
||||
redux(
|
||||
({ count }, { type }: { type: 'INCREMENT' | 'DECREMENT' }) => ({
|
||||
count: count + (type === 'INCREMENT' ? 1 : -1),
|
||||
}),
|
||||
{ count: 0 }
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
api.dispatch({ type: 'INCREMENT' })
|
||||
api.dispatch({ type: 'INCREMENT' })
|
||||
;(extensionSubscriber as (message: any) => void)({
|
||||
type: 'ACTION',
|
||||
payload: JSON.stringify({ type: 'DECREMENT' }),
|
||||
})
|
||||
|
||||
expect(extension.init.mock.calls).toMatchObject([[{ count: 0 }]])
|
||||
expect(extension.send.mock.calls).toMatchObject([
|
||||
[{ type: 'INCREMENT' }, { count: 1 }],
|
||||
[{ type: 'INCREMENT' }, { count: 2 }],
|
||||
[{ type: 'DECREMENT' }, { count: 1 }],
|
||||
])
|
||||
expect(api.getState()).toMatchObject({ count: 1 })
|
||||
|
||||
const originalConsoleWarn = console.warn
|
||||
console.warn = jest.fn()
|
||||
|
||||
api.dispatch({ type: '__setState' as any })
|
||||
expect(console.warn).toHaveBeenLastCalledWith(
|
||||
'[zustand devtools middleware] "__setState" action type is reserved ' +
|
||||
'to set state from the devtools. Avoid using it.'
|
||||
)
|
||||
|
||||
console.warn = originalConsoleWarn
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user