diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index 4367ce8da..ec025031b 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -6,7 +6,8 @@ "simple-react-browserify-x9yni", "simple-snowpack-react-o1gmx", "react-parcel-onewf", - "next-js-uo1h0" + "next-js-uo1h0", + "pavlobu-zustand-demo-frutec" ], "node": "14" } diff --git a/readme.md b/readme.md index 666e8ce80..bcf066c57 100644 --- a/readme.md +++ b/readme.md @@ -389,6 +389,21 @@ const usePlainStore = create(devtools(store)) const useReduxStore = create(devtools(redux(reducer, initialState))) ``` +One redux devtools connection for multiple stores + +```jsx +import { devtools } from 'zustand/middleware' + +// Usage with a plain action store, it will log actions as "setState" +const usePlainStore1 = create(devtools(store), { name, store: storeName1 }) +const usePlainStore2 = create(devtools(store), { name, store: storeName2 }) +// Usage with a redux store, it will log full action types +const useReduxStore = create(devtools(redux(reducer, initialState)), , { name, store: storeName3 }) +const useReduxStore = create(devtools(redux(reducer, initialState)), , { name, store: storeName4 }) +``` + +Assingning different connection names, will separate stores in redux devtools. This also helps grouping different stores into separate redux devtools connections. + devtools takes the store function as its first argument, optionally you can name the store or configure [serialize](https://github.com/zalmoxisus/redux-devtools-extension/blob/master/docs/API/Arguments.md#serialize) options with a second argument. Name store: `devtools(store, {name: "MyStore"})`, which will create a separate instance named "MyStore" in the devtools. diff --git a/src/middleware/devtools.ts b/src/middleware/devtools.ts index 1c6641d81..7e76426ee 100644 --- a/src/middleware/devtools.ts +++ b/src/middleware/devtools.ts @@ -122,6 +122,7 @@ type StoreDevtools = S extends { export interface DevtoolsOptions extends Config { enabled?: boolean anonymousActionType?: string + store?: string } type Devtools = < @@ -147,13 +148,63 @@ type DevtoolsImpl = ( export type NamedSet = WithDevtools>['setState'] +type Connection = ReturnType< + NonNullable['connect'] +> +type ConnectionName = string | undefined +type StoreName = string +type StoreInformation = StoreApi +type ConnectionInformation = { + connection: Connection + stores: Record +} +const trackedConnections: Map = new Map() + +const getTrackedConnectionState = ( + name: string | undefined +): Record => { + const api = trackedConnections.get(name) + if (!api) return {} + return Object.fromEntries( + Object.entries(api.stores).map(([key, api]) => [key, api.getState()]) + ) +} + +const extractConnectionInformation = ( + store: string | undefined, + extensionConnector: NonNullable< + typeof window['__REDUX_DEVTOOLS_EXTENSION__'] + >, + options: Omit +) => { + if (store === undefined) { + return { + type: 'untracked' as const, + connection: extensionConnector.connect(options), + } + } + const existingConnection = trackedConnections.get(options.name) + if (existingConnection) { + return { type: 'tracked' as const, store, ...existingConnection } + } + const newConnection: ConnectionInformation = { + connection: extensionConnector.connect(options), + stores: {}, + } + trackedConnections.set(options.name, newConnection) + return { type: 'tracked' as const, store, ...newConnection } +} + const devtoolsImpl: DevtoolsImpl = (fn, devtoolsOptions = {}) => (set, get, api) => { - type S = ReturnType + const { enabled, anonymousActionType, store, ...options } = devtoolsOptions + + type S = ReturnType & { + [store: string]: ReturnType + } type PartialState = Partial | ((s: S) => Partial) - const { enabled, anonymousActionType, ...options } = devtoolsOptions let extensionConnector: | typeof window['__REDUX_DEVTOOLS_EXTENSION__'] | false @@ -173,22 +224,36 @@ const devtoolsImpl: DevtoolsImpl = return fn(set, get, api) } - const extension = extensionConnector.connect(options) + const { connection, ...connectionInformation } = + extractConnectionInformation(store, extensionConnector, options) let isRecording = true ;(api.setState as NamedSet) = (state, replace, nameOrAction) => { const r = set(state, replace) if (!isRecording) return r - extension.send( + const action: Action = nameOrAction === undefined ? { type: anonymousActionType || 'anonymous' } : typeof nameOrAction === 'string' ? { type: nameOrAction } - : nameOrAction, - get() + : nameOrAction + if (store === undefined) { + connection?.send(action, get()) + return r + } + connection?.send( + { + ...action, + type: `${store}/${action.type}`, + }, + { + ...getTrackedConnectionState(options.name), + [store]: api.getState(), + } ) return r } + const setStateFromDevtools: StoreApi['setState'] = (...a) => { const originalIsRecording = isRecording isRecording = false @@ -197,7 +262,21 @@ const devtoolsImpl: DevtoolsImpl = } const initialState = fn(api.setState, get, api) - extension.init(initialState) + if (connectionInformation.type === 'untracked') { + connection?.init(initialState) + } else { + connectionInformation.stores[connectionInformation.store] = api + connection?.init( + Object.fromEntries( + Object.entries(connectionInformation.stores).map(([key, store]) => [ + key, + key === connectionInformation.store + ? initialState + : store.getState(), + ]) + ) + ) + } if ( (api as any).dispatchFromDevtools && @@ -222,7 +301,7 @@ const devtoolsImpl: DevtoolsImpl = } ;( - extension as unknown as { + connection as unknown as { // FIXME https://github.com/reduxjs/redux-devtools/issues/1097 subscribe: ( listener: (message: Message) => void @@ -241,7 +320,32 @@ const devtoolsImpl: DevtoolsImpl = message.payload, (action) => { if (action.type === '__setState') { - setStateFromDevtools(action.state as PartialState) + if (store === undefined) { + setStateFromDevtools(action.state as PartialState) + return + } + if (Object.keys(action.state as S).length !== 1) { + console.error( + ` + [zustand devtools middleware] Unsupported __setState action format. + When using 'store' option in devtools(), the 'state' should have only one key, which is a value of 'store' that was passed in devtools(), + and value of this only key should be a state object. Example: { "type": "__setState", "state": { "abc123Store": { "foo": "bar" } } } + ` + ) + } + const stateFromDevtools = (action.state as S)[store] + if ( + stateFromDevtools === undefined || + stateFromDevtools === null + ) { + return + } + if ( + JSON.stringify(api.getState()) !== + JSON.stringify(stateFromDevtools) + ) { + setStateFromDevtools(stateFromDevtools) + } return } @@ -254,22 +358,43 @@ const devtoolsImpl: DevtoolsImpl = case 'DISPATCH': switch (message.payload.type) { case 'RESET': - setStateFromDevtools(initialState) - return extension.init(api.getState()) + setStateFromDevtools(initialState as S) + if (store === undefined) { + return connection?.init(api.getState()) + } + return connection?.init(getTrackedConnectionState(options.name)) case 'COMMIT': - return extension.init(api.getState()) + if (store === undefined) { + connection?.init(api.getState()) + return + } + return connection?.init(getTrackedConnectionState(options.name)) case 'ROLLBACK': return parseJsonThen(message.state, (state) => { - setStateFromDevtools(state) - extension.init(api.getState()) + if (store === undefined) { + setStateFromDevtools(state) + connection?.init(api.getState()) + return + } + setStateFromDevtools(state[store] as S) + connection?.init(getTrackedConnectionState(options.name)) }) case 'JUMP_TO_STATE': case 'JUMP_TO_ACTION': return parseJsonThen(message.state, (state) => { - setStateFromDevtools(state) + if (store === undefined) { + setStateFromDevtools(state) + return + } + if ( + JSON.stringify(api.getState()) !== + JSON.stringify(state[store]) + ) { + setStateFromDevtools(state[store] as S) + } }) case 'IMPORT_STATE': { @@ -277,8 +402,12 @@ const devtoolsImpl: DevtoolsImpl = const lastComputedState = nextLiftedState.computedStates.slice(-1)[0]?.state if (!lastComputedState) return - setStateFromDevtools(lastComputedState) - extension.send( + if (store === undefined) { + setStateFromDevtools(lastComputedState) + } else { + setStateFromDevtools(lastComputedState[store]) + } + connection?.send( null as any, // FIXME no-any nextLiftedState ) diff --git a/tests/devtools.test.tsx b/tests/devtools.test.tsx index 7b8f47ab7..ea133d018 100644 --- a/tests/devtools.test.tsx +++ b/tests/devtools.test.tsx @@ -1,37 +1,139 @@ -import { devtools, redux } from 'zustand/middleware' -import create, { StoreApi } from 'zustand/vanilla' - -let extensionSubscriber: ((message: any) => void) | undefined -const extension = { - subscribe: jest.fn((f) => { - extensionSubscriber = f - return () => {} +import { StoreApi } from 'zustand/vanilla' + +const getImports = async () => { + const { devtools } = await import('zustand/middleware') + const create = (await import('zustand/vanilla')).default + return { create, devtools } +} + +type TupleOfEqualLengthH< + Arr extends unknown[], + T, + Acc extends T[] +> = Arr extends [unknown, ...infer Rest] + ? TupleOfEqualLengthH + : Acc +type TupleOfEqualLength = number extends Arr['length'] + ? T[] + : TupleOfEqualLengthH + +type Connection = { + subscribers: ((message: unknown) => void)[] + api: { + subscribe: jest.Mock<() => void, [f: any]> + unsubscribe: jest.Mock + send: jest.Mock + init: jest.Mock + error: jest.Mock + dispatch?: jest.Mock + } +} +const namedConnections = new Map() +const unnamedConnections = new Map() + +function assertAllAreDefined(arr: (T | undefined)[]): asserts arr is T[] { + if (arr.some((e) => e === undefined)) { + throw new Error() + } +} +function getNamedConnectionApis( + ...keys: Keys +) { + const apis = keys.map((k) => namedConnections.get(k)?.api) + assertAllAreDefined(apis) + return apis as TupleOfEqualLength +} +function getNamedConnectionSubscribers( + ...keys: Keys +) { + const subscribers = keys.map((k) => { + const subs = namedConnections.get(k)?.subscribers + if (subs?.length !== 1) throw new Error() + return subs[0] + }) + assertAllAreDefined(subscribers) + return subscribers as TupleOfEqualLength< + Keys, + Connection['subscribers'][number] + > +} +function getUnnamedConnectionApis(...keys: Keys) { + const apis = keys.map((k) => unnamedConnections.get(k)?.api) + assertAllAreDefined(apis) + return apis as TupleOfEqualLength +} +function getUnnamedConnectionSubscribers(...keys: Keys) { + const subscribers = keys.map((k) => { + const subs = unnamedConnections.get(k)?.subscribers + if (!subs) { + throw new Error() + } + return subs[0] + }) + assertAllAreDefined(subscribers) + return subscribers as TupleOfEqualLength< + Keys, + Connection['subscribers'][number] + > +} + +function getKeyFromOptions(options: any): string | undefined { + let key: string | undefined = options?.name + if (options?.testStore) { + key = `${options?.name}|${options?.testStore}` + } + return key +} + +const extensionConnector = { + connect: jest.fn((options) => { + const key = getKeyFromOptions(options) + //console.log('options', options) + const areNameUndefinedMapsNeeded = + options.testConnectionId !== undefined && options?.name === undefined + const connectionMap = areNameUndefinedMapsNeeded + ? unnamedConnections + : namedConnections + const subscribers: Connection['subscribers'] = [] + const api = { + subscribe: jest.fn((f) => { + subscribers.push(f) + return () => {} + }), + unsubscribe: jest.fn(), + send: jest.fn(), + init: jest.fn(), + error: jest.fn(), + } + connectionMap.set( + areNameUndefinedMapsNeeded ? options.testConnectionId : key, + { + subscribers, + api, + } + ) + return api }), - 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(() => { + jest.resetModules() extensionConnector.connect.mockClear() - extension.subscribe.mockClear() - extension.unsubscribe.mockClear() - extension.send.mockClear() - extension.init.mockClear() - extension.error.mockClear() - extensionSubscriber = undefined + namedConnections.clear() + unnamedConnections.clear() }) -it('connects to the extension by passing the options and initializes', () => { +it('connects to the extension by passing the options and initializes', async () => { + const { devtools, create } = await getImports() const options = { name: 'test', foo: 'bar' } const initialState = { count: 0 } create(devtools(() => initialState, { enabled: true, ...options })) expect(extensionConnector.connect).toHaveBeenLastCalledWith(options) - expect(extension.init).toHaveBeenLastCalledWith(initialState) + + const [conn] = getNamedConnectionApis(options.name) + expect(conn.init).toHaveBeenLastCalledWith(initialState) }) describe('If there is no extension installed...', () => { @@ -49,30 +151,35 @@ describe('If there is no extension installed...', () => { ;(window as any).__REDUX_DEVTOOLS_EXTENSION__ = extensionConnector }) - it('does not throw', () => { + it('does not throw', async () => { + const { devtools, create } = await getImports() expect(() => { create(devtools(() => ({ count: 0 }))) }).not.toThrow() }) - it('does not warn if not enabled', () => { + it('does not warn if not enabled', async () => { + const { devtools, create } = await getImports() create(devtools(() => ({ count: 0 }))) expect(console.warn).not.toBeCalled() }) - it('[DEV-ONLY] warns if enabled in dev mode', () => { + it('[DEV-ONLY] warns if enabled in dev mode', async () => { + const { devtools, create } = await getImports() __DEV__ = true create(devtools(() => ({ count: 0 }), { enabled: true })) expect(console.warn).toBeCalled() }) - it('[PRD-ONLY] does not warn if not in dev env', () => { + it('[PRD-ONLY] does not warn if not in dev env', async () => { + const { devtools, create } = await getImports() __DEV__ = false create(devtools(() => ({ count: 0 }))) expect(console.warn).not.toBeCalled() }) - it('[PRD-ONLY] does not warn if not in dev env even if enabled', () => { + it('[PRD-ONLY] does not warn if not in dev env even if enabled', async () => { + const { devtools, create } = await getImports() __DEV__ = false create(devtools(() => ({ count: 0 }), { enabled: true })) expect(console.warn).not.toBeCalled() @@ -80,16 +187,17 @@ describe('If there is no extension installed...', () => { }) describe('When state changes...', () => { - it("sends { type: setStateName || 'anonymous`, ...rest } as the action with current state", () => { - const api = create( - devtools(() => ({ count: 0, foo: 'bar' }), { - name: 'testOptionsName', - enabled: true, - }) - ) + it("sends { type: setStateName || 'anonymous`, ...rest } as the action with current state", async () => { + const { devtools, create } = await getImports() + const options = { + name: 'testOptionsName', + enabled: true, + } + const api = create(devtools(() => ({ count: 0, foo: 'bar' }), options)) api.setState({ count: 10 }, false, 'testSetStateName') - expect(extension.send).toHaveBeenLastCalledWith( + const [connection] = getNamedConnectionApis(options.name) + expect(connection.send).toHaveBeenLastCalledWith( { type: 'testSetStateName' }, { count: 10, foo: 'bar' } ) @@ -98,27 +206,29 @@ describe('When state changes...', () => { type: 'testSetStateName', payload: 15, }) - expect(extension.send).toHaveBeenLastCalledWith( + expect(connection.send).toHaveBeenLastCalledWith( { type: 'testSetStateName', payload: 15 }, { count: 15, foo: 'bar' } ) api.setState({ count: 5, foo: 'baz' }, true) - expect(extension.send).toHaveBeenLastCalledWith( + expect(connection.send).toHaveBeenLastCalledWith( { type: 'anonymous' }, { count: 5, foo: 'baz' } ) }) }) -describe('when it receives an message of type...', () => { +describe('when it receives a message of type...', () => { describe('ACTION...', () => { - it('does nothing', () => { + it('does nothing', async () => { + const { devtools, create } = await getImports() const initialState = { count: 0 } const api = create(devtools(() => initialState, { enabled: true })) const setState = jest.spyOn(api, 'setState') - ;(extensionSubscriber as (message: any) => void)({ + const [subscriber] = getNamedConnectionSubscribers(undefined) + subscriber({ type: 'ACTION', payload: '{ "type": "INCREMENT" }', }) @@ -127,11 +237,13 @@ describe('when it receives an message of type...', () => { expect(setState).not.toBeCalled() }) - it('unless action type is __setState', () => { + it('unless action type is __setState', async () => { + const { devtools, create } = await getImports() const initialState = { count: 0 } const api = create(devtools(() => initialState, { enabled: true })) - ;(extensionSubscriber as (message: any) => void)({ + const [connectionSubscriber] = getNamedConnectionSubscribers(undefined) + connectionSubscriber({ type: 'ACTION', payload: '{ "type": "__setState", "state": { "foo": "bar" } }', }) @@ -139,13 +251,15 @@ describe('when it receives an message of type...', () => { expect(api.getState()).toStrictEqual({ ...initialState, foo: 'bar' }) }) - it('does nothing even if there is `api.dispatch`', () => { + it('does nothing even if there is `api.dispatch`', async () => { + const { devtools, create } = await getImports() const initialState = { count: 0 } const api = create(devtools(() => initialState, { enabled: true })) ;(api as any).dispatch = jest.fn() const setState = jest.spyOn(api, 'setState') - ;(extensionSubscriber as (message: any) => void)({ + const [connectionSubscriber] = getNamedConnectionSubscribers(undefined) + connectionSubscriber({ type: 'ACTION', payload: '{ "type": "INCREMENT" }', }) @@ -155,14 +269,16 @@ describe('when it receives an message of type...', () => { expect((api as any).dispatch).not.toBeCalled() }) - it('dispatches with `api.dispatch` when `api.dispatchFromDevtools` is set to true', () => { + it('dispatches with `api.dispatch` when `api.dispatchFromDevtools` is set to true', async () => { + const { devtools, create } = await getImports() const initialState = { count: 0 } const api = create(devtools(() => initialState, { enabled: true })) ;(api as any).dispatch = jest.fn() ;(api as any).dispatchFromDevtools = true const setState = jest.spyOn(api, 'setState') - ;(extensionSubscriber as (message: any) => void)({ + const [connectionSubscriber] = getNamedConnectionSubscribers(undefined) + connectionSubscriber({ type: 'ACTION', payload: '{ "type": "INCREMENT" }', }) @@ -174,7 +290,8 @@ describe('when it receives an message of type...', () => { }) }) - it('does not throw for unsupported payload', () => { + it('does not throw for unsupported payload', async () => { + const { devtools, create } = await getImports() const initialState = { count: 0 } const api = create(devtools(() => initialState, { enabled: true })) ;(api as any).dispatch = jest.fn() @@ -183,8 +300,9 @@ describe('when it receives an message of type...', () => { const originalConsoleError = console.error console.error = jest.fn() + const [connectionSubscriber] = getNamedConnectionSubscribers(undefined) expect(() => { - ;(extensionSubscriber as (message: any) => void)({ + connectionSubscriber({ type: 'ACTION', payload: 'this.increment()', }) @@ -202,7 +320,7 @@ describe('when it receives an message of type...', () => { ) expect(() => { - ;(extensionSubscriber as (message: any) => void)({ + connectionSubscriber({ type: 'ACTION', payload: { name: 'increment', args: [] }, }) @@ -221,69 +339,81 @@ describe('when it receives an message of type...', () => { }) describe('DISPATCH and payload of type...', () => { - it('RESET, it inits with initial state', () => { + it('RESET, it inits with initial state', async () => { + const { devtools, create } = await getImports() const initialState = { count: 0 } const api = create(devtools(() => initialState, { enabled: true })) api.setState({ count: 1 }) - extension.send.mockClear() - ;(extensionSubscriber as (message: any) => void)({ + const [connection] = getNamedConnectionApis(undefined) + connection.send.mockClear() + const [connectionSubscriber] = getNamedConnectionSubscribers(undefined) + connectionSubscriber({ type: 'DISPATCH', payload: { type: 'RESET' }, }) expect(api.getState()).toStrictEqual(initialState) - expect(extension.init).toHaveBeenLastCalledWith(initialState) - expect(extension.send).not.toBeCalled() + expect(connection.init).toHaveBeenLastCalledWith(initialState) + expect(connection.send).not.toBeCalled() }) - it('COMMIT, it inits with current state', () => { + it('COMMIT, it inits with current state', async () => { + const { devtools, create } = await getImports() const initialState = { count: 0 } const api = create(devtools(() => initialState, { enabled: true })) api.setState({ count: 2 }) const currentState = api.getState() - extension.send.mockClear() - ;(extensionSubscriber as (message: any) => void)({ + const [connection] = getNamedConnectionApis(undefined) + connection.send.mockClear() + const [connectionSubscriber] = getNamedConnectionSubscribers(undefined) + connectionSubscriber({ type: 'DISPATCH', payload: { type: 'COMMIT' }, }) - expect(extension.init).toHaveBeenLastCalledWith(currentState) - expect(extension.send).not.toBeCalled() + expect(connection.init).toHaveBeenLastCalledWith(currentState) + expect(connection.send).not.toBeCalled() }) describe('ROLLBACK...', () => { - it('it updates state without recording and inits with `message.state`', () => { + it('it updates state without recording and inits with `message.state`', async () => { + const { devtools, create } = await getImports() const initialState = { count: 0, increment: () => {} } const api = create(devtools(() => initialState, { enabled: true })) const newState = { foo: 'bar' } - extension.send.mockClear() - ;(extensionSubscriber as (message: any) => void)({ + const [connection] = getNamedConnectionApis(undefined) + connection.send.mockClear() + const [connectionSubscriber] = getNamedConnectionSubscribers(undefined) + connectionSubscriber({ type: 'DISPATCH', payload: { type: 'ROLLBACK' }, state: JSON.stringify(newState), }) expect(api.getState()).toStrictEqual({ ...initialState, ...newState }) - expect(extension.init).toHaveBeenLastCalledWith({ + expect(connection.init).toHaveBeenLastCalledWith({ ...initialState, ...newState, }) - expect(extension.send).not.toBeCalled() + expect(connection.send).not.toBeCalled() }) - it('does not throw for unparsable `message.state`', () => { + it('does not throw for unparsable `message.state`', async () => { + const { devtools, create } = await getImports() const increment = () => {} const initialState = { count: 0, increment } const api = create(devtools(() => initialState, { enabled: true })) const originalConsoleError = console.error console.error = jest.fn() - extension.init.mockClear() - extension.send.mockClear() - ;(extensionSubscriber as (message: any) => void)({ + const [connection] = getNamedConnectionApis(undefined) + connection.init.mockClear() + connection.send.mockClear() + const [connectionSubscriber] = getNamedConnectionSubscribers(undefined) + connectionSubscriber({ type: 'DISPATCH', payload: { type: 'ROLLBACK' }, state: 'foobar', @@ -300,8 +430,8 @@ describe('when it receives an message of type...', () => { })() ) expect(api.getState()).toBe(initialState) - expect(extension.init).not.toBeCalled() - expect(extension.send).not.toBeCalled() + expect(connection.init).not.toBeCalled() + expect(connection.send).not.toBeCalled() console.error = originalConsoleError }) @@ -309,29 +439,35 @@ describe('when it receives an message of type...', () => { describe('JUMP_TO_STATE...', () => { const increment = () => {} - it('it updates state without recording with `message.state`', () => { + it('it updates state without recording with `message.state`', async () => { + const { devtools, create } = await getImports() const initialState = { count: 0, increment } const api = create(devtools(() => initialState, { enabled: true })) const newState = { foo: 'bar' } - extension.send.mockClear() - ;(extensionSubscriber as (message: any) => void)({ + const [connection] = getNamedConnectionApis(undefined) + connection.send.mockClear() + const [connectionSubscriber] = getNamedConnectionSubscribers(undefined) + connectionSubscriber({ type: 'DISPATCH', payload: { type: 'JUMP_TO_STATE' }, state: JSON.stringify(newState), }) expect(api.getState()).toStrictEqual({ ...initialState, ...newState }) - expect(extension.send).not.toBeCalled() + expect(connection.send).not.toBeCalled() }) - it('does not throw for unparsable `message.state`', () => { + it('does not throw for unparsable `message.state`', async () => { + const { devtools, create } = await getImports() const initialState = { count: 0, increment: () => {} } const api = create(devtools(() => initialState, { enabled: true })) const originalConsoleError = console.error console.error = jest.fn() - extension.send.mockClear() - ;(extensionSubscriber as (message: any) => void)({ + const [connection] = getNamedConnectionApis(undefined) + connection.send.mockClear() + const [connectionSubscriber] = getNamedConnectionSubscribers(undefined) + connectionSubscriber({ type: 'DISPATCH', payload: { type: 'JUMP_TO_STATE' }, state: 'foobar', @@ -348,37 +484,43 @@ describe('when it receives an message of type...', () => { })() ) expect(api.getState()).toBe(initialState) - expect(extension.send).not.toBeCalled() + expect(connection.send).not.toBeCalled() console.error = originalConsoleError }) }) describe('JUMP_TO_ACTION...', () => { - it('it updates state without recording with `message.state`', () => { + it('it updates state without recording with `message.state`', async () => { + const { devtools, create } = await getImports() const initialState = { count: 0, increment: () => {} } const api = create(devtools(() => initialState, { enabled: true })) const newState = { foo: 'bar' } - extension.send.mockClear() - ;(extensionSubscriber as (message: any) => void)({ + const [connection] = getNamedConnectionApis(undefined) + connection.send.mockClear() + const [connectionSubscriber] = getNamedConnectionSubscribers(undefined) + connectionSubscriber({ type: 'DISPATCH', payload: { type: 'JUMP_TO_ACTION' }, state: JSON.stringify(newState), }) expect(api.getState()).toStrictEqual({ ...initialState, ...newState }) - expect(extension.send).not.toBeCalled() + expect(connection.send).not.toBeCalled() }) - it('does not throw for unparsable `message.state`', () => { + it('does not throw for unparsable `message.state`', async () => { + const { devtools, create } = await getImports() const increment = () => {} const initialState = { count: 0, increment } const api = create(devtools(() => initialState, { enabled: true })) const originalConsoleError = console.error console.error = jest.fn() - extension.send.mockClear() - ;(extensionSubscriber as (message: any) => void)({ + const [connection] = getNamedConnectionApis(undefined) + connection.send.mockClear() + const [connectionSubscriber] = getNamedConnectionSubscribers(undefined) + connectionSubscriber({ type: 'DISPATCH', payload: { type: 'JUMP_TO_ACTION' }, state: 'foobar', @@ -395,21 +537,24 @@ describe('when it receives an message of type...', () => { })() ) expect(api.getState()).toBe(initialState) - expect(extension.send).not.toBeCalled() + expect(connection.send).not.toBeCalled() console.error = originalConsoleError }) }) - it('IMPORT_STATE, it updates state without recording and inits the last computedState', () => { + it('IMPORT_STATE, it updates state without recording and inits the last computedState', async () => { + const { devtools, create } = await getImports() const initialState = { count: 0, increment: () => {} } const api = create(devtools(() => initialState, { enabled: true })) const nextLiftedState = { computedStates: [{ state: { count: 4 } }, { state: { count: 5 } }], } - extension.send.mockClear() - ;(extensionSubscriber as (message: any) => void)({ + const [connection] = getNamedConnectionApis(undefined) + connection.send.mockClear() + const [connectionSubscriber] = getNamedConnectionSubscribers(undefined) + connectionSubscriber({ type: 'DISPATCH', payload: { type: 'IMPORT_STATE', @@ -420,34 +565,37 @@ describe('when it receives an message of type...', () => { ...initialState, ...nextLiftedState.computedStates.slice(-1)[0]?.state, }) - expect(extension.send).toHaveBeenLastCalledWith(null, nextLiftedState) + expect(connection.send).toHaveBeenLastCalledWith(null, nextLiftedState) }) - it('PAUSE_RECORDING, it toggles the sending of actions', () => { + it('PAUSE_RECORDING, it toggles the sending of actions', async () => { + const { devtools, create } = await getImports() const api = create(devtools(() => ({ count: 0 }), { enabled: true })) api.setState({ count: 1 }, false, 'increment') - expect(extension.send).toHaveBeenLastCalledWith( + const [connection] = getNamedConnectionApis(undefined) + const [connectionSubscriber] = getNamedConnectionSubscribers(undefined) + expect(connection.send).toHaveBeenLastCalledWith( { type: 'increment' }, { count: 1 } ) - ;(extensionSubscriber as (message: any) => void)({ + connectionSubscriber({ type: 'DISPATCH', payload: { type: 'PAUSE_RECORDING' }, }) api.setState({ count: 2 }, false, 'increment') - expect(extension.send).toHaveBeenLastCalledWith( + expect(connection.send).toHaveBeenLastCalledWith( { type: 'increment' }, { count: 1 } ) - ;(extensionSubscriber as (message: any) => void)({ + connectionSubscriber({ type: 'DISPATCH', payload: { type: 'PAUSE_RECORDING' }, }) api.setState({ count: 3 }, false, 'increment') - expect(extension.send).toHaveBeenLastCalledWith( + expect(connection.send).toHaveBeenLastCalledWith( { type: 'increment' }, { count: 3 } ) @@ -463,7 +611,9 @@ describe('with redux middleware', () => { ) => { type: 'INCREMENT' } | { type: 'DECREMENT' } }> - it('works as expected', () => { + it('works as expected', async () => { + const { devtools, redux } = await import('zustand/middleware') + const create = (await import('zustand/vanilla')).default api = create( devtools( redux( @@ -480,13 +630,15 @@ describe('with redux middleware', () => { ) ;(api as any).dispatch({ type: 'INCREMENT' }) ;(api as any).dispatch({ type: 'INCREMENT' }) - ;(extensionSubscriber as (message: any) => void)({ + const [connection] = getNamedConnectionApis(undefined) + const [connectionSubscriber] = getNamedConnectionSubscribers(undefined) + connectionSubscriber({ type: 'ACTION', payload: JSON.stringify({ type: 'DECREMENT' }), }) - expect(extension.init.mock.calls).toMatchObject([[{ count: 0 }]]) - expect(extension.send.mock.calls).toMatchObject([ + expect(connection.init.mock.calls).toMatchObject([[{ count: 0 }]]) + expect(connection.send.mock.calls).toMatchObject([ [{ type: 'INCREMENT' }, { count: 1 }], [{ type: 'INCREMENT' }, { count: 2 }], [{ type: 'DECREMENT' }, { count: 1 }], @@ -507,7 +659,8 @@ describe('with redux middleware', () => { }) }) -it('works in non-browser env', () => { +it('works in non-browser env', async () => { + const { devtools, create } = await getImports() const originalWindow = global.window global.window = undefined as any @@ -518,7 +671,8 @@ it('works in non-browser env', () => { global.window = originalWindow }) -it('works in react native env', () => { +it('works in react native env', async () => { + const { devtools, create } = await getImports() const originalWindow = global.window global.window = {} as any @@ -529,17 +683,1775 @@ it('works in react native env', () => { global.window = originalWindow }) -it('preserves isRecording after setting from devtools', () => { +it('preserves isRecording after setting from devtools', async () => { + const { devtools, create } = await getImports() const api = create(devtools(() => ({ count: 0 }), { enabled: true })) - ;(extensionSubscriber as (message: any) => void)({ + const [connection] = getNamedConnectionApis(undefined) + const [connectionSubscriber] = getNamedConnectionSubscribers(undefined) + connectionSubscriber({ type: 'DISPATCH', payload: { type: 'PAUSE_RECORDING' }, }) - ;(extensionSubscriber as (message: any) => void)({ + connectionSubscriber({ type: 'ACTION', payload: '{ "type": "__setState", "state": { "foo": "bar" } }', }) api.setState({ count: 1 }) - expect(extension.send).not.toBeCalled() + expect(connection.send).not.toBeCalled() +}) + +/* features: + * [] if name is undefined - use multiple devtools connections. + * [] if name and store is defined - use connection for specific 'name'. + * [] if two stores are coonected to one 'name' group and. + * another connected to another 'name' group, then feature should work + * [] check actions with this feature, for multiple stores that store prefixes are added - + * [] - reset + * [] - commit + * [] - rollback + * [] - jump to state, jump to action + * [] - import state + **/ + +describe('when redux connection was called on multiple stores with `name` undefined in `devtools` options', () => { + it('should create separate connection for each devtools store with .connect call', async () => { + const { devtools, create } = await getImports() + const options1 = { foo: 'bar', testConnectionId: 'asdf' } + const options2 = { foo: 'barr', testConnectionId: '123asd' } + const initialState1 = { count: 0 } + const initialState2 = { count1: 1 } + + create(devtools(() => initialState1, { enabled: true, ...options1 })) + create(devtools(() => initialState2, { enabled: true, ...options2 })) + + expect(extensionConnector.connect).toHaveBeenNthCalledWith(1, options1) + expect(extensionConnector.connect).toHaveBeenNthCalledWith(2, options2) + }) + + it('should call .init on each different connection object', async () => { + const { devtools, create } = await getImports() + const options1 = { foo: 'bar', testConnectionId: 'asdf' } + const options2 = { foo: 'barr', testConnectionId: '123asd' } + const initialState1 = { count: 0 } + const initialState2 = { count1: 1 } + + create(devtools(() => initialState1, { enabled: true, ...options1 })) + create(devtools(() => initialState2, { enabled: true, ...options2 })) + + const [conn1, conn2] = getUnnamedConnectionApis( + options1.testConnectionId, + options2.testConnectionId + ) + expect(conn1.init).toHaveBeenCalledWith(initialState1) + expect(conn2.init).toHaveBeenCalledWith(initialState2) + }) + + describe('when `store` property was provided in `devtools` call in options', () => { + it('should create single connection for all indernal calls of .connect and `store` is not passed to .connect', async () => { + const { devtools, create } = await getImports() + const options1 = { store: 'store1123', foo: 'bar1' } + const options2 = { store: 'store2313132', foo: 'bar2' } + const initialState1 = { count: 0 } + const initialState2 = { count1: 1 } + + create(devtools(() => initialState1, { enabled: true, ...options1 })) + create(devtools(() => initialState2, { enabled: true, ...options2 })) + + expect(extensionConnector.connect).toHaveBeenCalledTimes(1) + expect(extensionConnector.connect).toHaveBeenCalledWith({ + foo: options1.foo, + }) + }) + + it('should call `.init` on single connection with combined states after each `create(devtools` call', async () => { + const { devtools, create } = await getImports() + const options1 = { store: 'store12' } + const options2 = { store: 'store21' } + const initialState1 = { count1: 0 } + const initialState2 = { count2: 1 } + + create(devtools(() => initialState1, { enabled: true, ...options1 })) + create(devtools(() => initialState2, { enabled: true, ...options2 })) + + expect(extensionConnector.connect).toHaveBeenCalledTimes(1) + const [connection] = getNamedConnectionApis(undefined) + expect(connection.init).toHaveBeenCalledTimes(2) + expect(connection.init).toHaveBeenNthCalledWith(1, { + [options1.store]: initialState1, + }) + expect(connection.init).toHaveBeenNthCalledWith(2, { + [options1.store]: initialState1, + [options2.store]: initialState2, + }) + }) + }) +}) + +describe('when redux connection was called on multiple stores with `name` provided in `devtools` options', () => { + describe('when same `name` is provided to all stores in devtools options', () => { + it('should call .connect of redux extension with `name` that was passed from `devtools` options', async () => { + const { devtools, create } = await getImports() + const connectionName = 'test' + const options1 = { name: connectionName, store: 'store1123', foo: 'bar1' } + const options2 = { name: connectionName, store: 'store1414', foo: 'bar1' } + const initialState1 = { count: 0 } + const initialState2 = { count: 2 } + + create(devtools(() => initialState1, { enabled: true, ...options1 })) + create(devtools(() => initialState2, { enabled: true, ...options2 })) + + expect(extensionConnector.connect).toHaveBeenCalledTimes(1) + expect(extensionConnector.connect).toHaveBeenCalledWith({ + foo: options1.foo, + name: connectionName, + }) + }) + }) + + describe('when different `name` props were provided for different group of stores in devtools options', () => { + it('should call .connect of redux extension with `name` that was passed from `devtools` options', async () => { + const { devtools, create } = await getImports() + const connectionNameGroup1 = 'test1' + const connectionNameGroup2 = 'test2' + const options1 = { + name: connectionNameGroup1, + store: 'store1123', + foo: 'bar2', + } + const options2 = { + name: connectionNameGroup1, + store: 'store1232', + foo: 'bar3', + } + const options3 = { + name: connectionNameGroup2, + store: 'store61661', + foo: 'bar4', + } + const options4 = { + name: connectionNameGroup2, + store: 'store14632', + foo: 'bar5', + } + const initialState1 = { count: 0 } + const initialState2 = { count: 2 } + const initialState3 = { count: 5 } + const initialState4 = { count: 7 } + + create(devtools(() => initialState1, { enabled: true, ...options1 })) + create(devtools(() => initialState2, { enabled: true, ...options2 })) + create(devtools(() => initialState3, { enabled: true, ...options3 })) + create(devtools(() => initialState4, { enabled: true, ...options4 })) + + expect(extensionConnector.connect).toHaveBeenCalledTimes(2) + expect(extensionConnector.connect).toHaveBeenNthCalledWith(1, { + foo: options1.foo, + name: connectionNameGroup1, + }) + expect(extensionConnector.connect).toHaveBeenNthCalledWith(2, { + foo: options3.foo, + name: connectionNameGroup2, + }) + }) + + it('should call `.init` on single connection with combined states after each `create(devtools` call', async () => { + const { devtools, create } = await getImports() + const connectionNameGroup1 = 'test1' + const connectionNameGroup2 = 'test2' + const options1 = { + name: connectionNameGroup1, + store: 'store1123', + foo: 'bar2', + } + const options2 = { + name: connectionNameGroup1, + store: 'store1232', + foo: 'bar3', + } + const options3 = { + name: connectionNameGroup2, + store: 'store61661', + foo: 'bar4', + } + const options4 = { + name: connectionNameGroup2, + store: 'store14632', + foo: 'bar5', + } + const initialState1 = { count: 0 } + const initialState2 = { count: 2 } + const initialState3 = { count: 5 } + const initialState4 = { count: 7 } + + create(devtools(() => initialState1, { enabled: true, ...options1 })) + create(devtools(() => initialState2, { enabled: true, ...options2 })) + create(devtools(() => initialState3, { enabled: true, ...options3 })) + create(devtools(() => initialState4, { enabled: true, ...options4 })) + + expect(extensionConnector.connect).toHaveBeenCalledTimes(2) + const [connection1, connection2] = getNamedConnectionApis( + connectionNameGroup1, + connectionNameGroup2 + ) + expect(connection1.init).toHaveBeenCalledTimes(2) + expect(connection1.init).toHaveBeenNthCalledWith(1, { + [options1.store]: initialState1, + }) + expect(connection1.init).toHaveBeenNthCalledWith(2, { + [options1.store]: initialState1, + [options2.store]: initialState2, + }) + expect(connection2.init).toHaveBeenCalledTimes(2) + expect(connection2.init).toHaveBeenNthCalledWith(1, { + [options3.store]: initialState3, + }) + expect(connection2.init).toHaveBeenNthCalledWith(2, { + [options3.store]: initialState3, + [options4.store]: initialState4, + }) + }) + + it('preserves isRecording after setting from devtools on proper connection subscriber', async () => { + const { devtools, create } = await getImports() + const options1 = { name: 'asdf1' } + const options2 = { name: 'asdf2' } + const api1 = create( + devtools(() => ({ count: 0 }), { enabled: true, ...options1 }) + ) + create(devtools(() => ({ count: 0 }), { enabled: true, ...options2 })) + const connections = getNamedConnectionApis(options1.name, options2.name) + const [connectionSubscriber] = getNamedConnectionSubscribers( + options1.name + ) + connectionSubscriber({ + type: 'DISPATCH', + payload: { type: 'PAUSE_RECORDING' }, + }) + connectionSubscriber({ + type: 'ACTION', + payload: '{ "type": "__setState", "state": { "foo": "bar" } }', + }) + + api1.setState({ count: 1 }) + connections.forEach((conn) => expect(conn.send).not.toBeCalled()) + }) + + describe('with redux middleware', () => { + let api1: StoreApi<{ + count: number + dispatch: ( + action: { type: 'INCREMENT' } | { type: 'DECREMENT' } + ) => { type: 'INCREMENT' } | { type: 'DECREMENT' } + }> + let api2: StoreApi<{ + count: number + dispatch: ( + action: { type: 'INCREMENT' } | { type: 'DECREMENT' } + ) => { type: 'INCREMENT' } | { type: 'DECREMENT' } + }> + + it('works as expected', async () => { + const { devtools, redux } = await import('zustand/middleware') + const create = (await import('zustand/vanilla')).default + const options1 = { testConnectionId: 'asdf' } + const options2 = { testConnectionId: '2f' } + api1 = create( + devtools( + redux( + ( + { count }, + { type }: { type: 'INCREMENT' } | { type: 'DECREMENT' } + ) => ({ + count: count + (type === 'INCREMENT' ? 1 : -1), + }), + { count: 0 } + ), + { enabled: true, ...options1 } + ) + ) + api2 = create( + devtools( + redux( + ( + { count }, + { type }: { type: 'INCREMENT' } | { type: 'DECREMENT' } + ) => ({ + count: count + (type === 'INCREMENT' ? 1 : -1), + }), + { count: 10 } + ), + { enabled: true, ...options2 } + ) + ) + ;(api1 as any).dispatch({ type: 'INCREMENT' }) + ;(api1 as any).dispatch({ type: 'INCREMENT' }) + ;(api2 as any).dispatch({ type: 'INCREMENT' }) + ;(api2 as any).dispatch({ type: 'INCREMENT' }) + const [connection1, connection2] = getUnnamedConnectionApis( + options1.testConnectionId, + options2.testConnectionId + ) + const [connectionSubscriber1, connectionSubscriber2] = + getUnnamedConnectionSubscribers( + options1.testConnectionId, + options2.testConnectionId + ) + connectionSubscriber1({ + type: 'ACTION', + payload: JSON.stringify({ type: 'DECREMENT' }), + }) + connectionSubscriber2({ + type: 'ACTION', + payload: JSON.stringify({ type: 'DECREMENT' }), + }) + + expect(connection1.init.mock.calls).toMatchObject([[{ count: 0 }]]) + expect(connection2.init.mock.calls).toMatchObject([[{ count: 10 }]]) + expect(connection1.send.mock.calls).toMatchObject([ + [{ type: 'INCREMENT' }, { count: 1 }], + [{ type: 'INCREMENT' }, { count: 2 }], + [{ type: 'DECREMENT' }, { count: 1 }], + ]) + expect(connection2.send.mock.calls).toMatchObject([ + [{ type: 'INCREMENT' }, { count: 11 }], + [{ type: 'INCREMENT' }, { count: 12 }], + [{ type: 'DECREMENT' }, { count: 11 }], + ]) + expect(api1.getState()).toMatchObject({ count: 1 }) + expect(api2.getState()).toMatchObject({ count: 11 }) + }) + }) + }) +}) + +describe('when create devtools was called multiple times with `name` option undefined', () => { + describe('When state changes...', () => { + it("sends { type: setStateName || 'anonymous`, ...rest } as the action with current state, isolated from other connections", async () => { + const { devtools, create } = await getImports() + const options1 = { + enabled: true, + testConnectionId: '123', + } + const options2 = { + enabled: true, + testConnectionId: '324', + } + const options3 = { + enabled: true, + testConnectionId: '412', + } + const api1 = create(devtools(() => ({ count: 0, foo: 'bar' }), options1)) + create(devtools(() => ({ count: 0, foo: 'bar1' }), options2)) + create(devtools(() => ({ count: 0, foo: 'bar2' }), options3)) + + api1.setState({ count: 10 }, false, 'testSetStateName') + const [connection1, connection2, connection3] = getUnnamedConnectionApis( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + expect(connection1.send).toHaveBeenLastCalledWith( + { type: 'testSetStateName' }, + { count: 10, foo: 'bar' } + ) + expect(connection2.send).not.toBeCalled() + expect(connection3.send).not.toBeCalled() + + api1.setState({ count: 15 }, false, { + type: 'testSetStateName', + payload: 15, + }) + expect(connection1.send).toHaveBeenLastCalledWith( + { type: 'testSetStateName', payload: 15 }, + { count: 15, foo: 'bar' } + ) + expect(connection2.send).not.toBeCalled() + expect(connection3.send).not.toBeCalled() + + api1.setState({ count: 5, foo: 'baz' }, true) + expect(connection1.send).toHaveBeenLastCalledWith( + { type: 'anonymous' }, + { count: 5, foo: 'baz' } + ) + expect(connection2.send).not.toBeCalled() + expect(connection3.send).not.toBeCalled() + }) + }) + + describe('when it receives a message of type...', () => { + describe('ACTION...', () => { + it('does nothing, connections isolated from each other', async () => { + const { devtools, create } = await getImports() + const options1 = { testConnectionId: '123' } + const options2 = { testConnectionId: '231' } + const options3 = { testConnectionId: '4342' } + const initialState1 = { count: 0 } + const initialState2 = { count: 2 } + const initialState3 = { count: 3 } + const api1 = create( + devtools(() => initialState1, { + enabled: true, + ...options1, + }) + ) + const api2 = create( + devtools(() => initialState2, { + enabled: true, + ...options2, + }) + ) + const api3 = create( + devtools(() => initialState3, { + enabled: true, + ...options3, + }) + ) + const setState1 = jest.spyOn(api1, 'setState') + const setState2 = jest.spyOn(api2, 'setState') + const setState3 = jest.spyOn(api3, 'setState') + + const [subscriber] = getUnnamedConnectionSubscribers( + options1.testConnectionId + ) + subscriber({ + type: 'ACTION', + payload: '{ "type": "INCREMENT" }', + }) + + expect(api1.getState()).toBe(initialState1) + expect(api2.getState()).toBe(initialState2) + expect(api3.getState()).toBe(initialState3) + expect(setState1).not.toBeCalled() + expect(setState2).not.toBeCalled() + expect(setState3).not.toBeCalled() + }) + + it('unless action type is __setState, connections isolated from each other', async () => { + const { devtools, create } = await getImports() + const options1 = { testConnectionId: 'asdf' } + const options2 = { testConnectionId: '2f' } + const options3 = { testConnectionId: 'd2e' } + const initialState1 = { count: 0 } + const initialState2 = { count: 2 } + const initialState3 = { count: 5 } + const api1 = create( + devtools(() => initialState1, { enabled: true, ...options1 }) + ) + const api2 = create( + devtools(() => initialState2, { enabled: true, ...options2 }) + ) + const api3 = create( + devtools(() => initialState3, { enabled: true, ...options3 }) + ) + + const [connectionSubscriber] = getUnnamedConnectionSubscribers( + options1.testConnectionId + ) + connectionSubscriber({ + type: 'ACTION', + payload: '{ "type": "__setState", "state": { "foo": "bar" } }', + }) + + expect(api1.getState()).toStrictEqual({ ...initialState1, foo: 'bar' }) + expect(api2.getState()).toStrictEqual({ ...initialState2 }) + expect(api3.getState()).toStrictEqual({ ...initialState3 }) + }) + + it('does nothing even if there is `api.dispatch`, connections isolated from each other', async () => { + const { devtools, create } = await getImports() + const options1 = { testConnectionId: 'asdf' } + const options2 = { testConnectionId: '2f' } + const options3 = { testConnectionId: 'd2e' } + const initialState1 = { count: 0 } + const initialState2 = { count: 2 } + const initialState3 = { count: 5 } + const api1 = create( + devtools(() => initialState1, { enabled: true, ...options1 }) + ) + const api2 = create( + devtools(() => initialState2, { enabled: true, ...options2 }) + ) + const api3 = create( + devtools(() => initialState3, { enabled: true, ...options3 }) + ) + ;(api1 as any).dispatch = jest.fn() + ;(api2 as any).dispatch = jest.fn() + ;(api3 as any).dispatch = jest.fn() + const setState1 = jest.spyOn(api1, 'setState') + const setState2 = jest.spyOn(api2, 'setState') + const setState3 = jest.spyOn(api3, 'setState') + + const subscribers = getUnnamedConnectionSubscribers( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + const testPayload = { + type: 'ACTION', + payload: '{ "type": "INCREMENT" }', + } + subscribers.forEach((sub) => sub(testPayload)) + + expect(api1.getState()).toBe(initialState1) + expect(api2.getState()).toBe(initialState2) + expect(api3.getState()).toBe(initialState3) + expect(setState1).not.toBeCalled() + expect(setState2).not.toBeCalled() + expect(setState3).not.toBeCalled() + expect((api1 as any).dispatch).not.toBeCalled() + expect((api2 as any).dispatch).not.toBeCalled() + expect((api3 as any).dispatch).not.toBeCalled() + }) + + it('dispatches with `api.dispatch` when `api.dispatchFromDevtools` is set to true, connections are isolated from each other', async () => { + const { devtools, create } = await getImports() + const options1 = { testConnectionId: 'asdf' } + const options2 = { testConnectionId: '2f' } + const options3 = { testConnectionId: 'd2e' } + const initialState1 = { count: 0 } + const initialState2 = { count: 2 } + const initialState3 = { count: 5 } + const api1 = create( + devtools(() => initialState1, { enabled: true, ...options1 }) + ) + const api2 = create( + devtools(() => initialState2, { enabled: true, ...options2 }) + ) + const api3 = create( + devtools(() => initialState3, { enabled: true, ...options3 }) + ) + ;(api1 as any).dispatch = jest.fn() + ;(api1 as any).dispatchFromDevtools = true + ;(api2 as any).dispatch = jest.fn() + ;(api2 as any).dispatchFromDevtools = true + ;(api3 as any).dispatch = jest.fn() + ;(api3 as any).dispatchFromDevtools = true + const setState1 = jest.spyOn(api1, 'setState') + const setState2 = jest.spyOn(api2, 'setState') + const setState3 = jest.spyOn(api3, 'setState') + + const subscribers = getUnnamedConnectionSubscribers( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + const getTestPayload = (n: number) => ({ + type: 'ACTION', + payload: `{ "type": "INCREMENT${n}" }`, + }) + subscribers.forEach((sub, i) => sub(getTestPayload(i + 1))) + + expect(api1.getState()).toBe(initialState1) + expect(api2.getState()).toBe(initialState2) + expect(api3.getState()).toBe(initialState3) + expect(setState1).not.toBeCalled() + expect(setState2).not.toBeCalled() + expect(setState3).not.toBeCalled() + expect((api1 as any).dispatch).toHaveBeenLastCalledWith({ + type: 'INCREMENT1', + }) + expect((api2 as any).dispatch).toHaveBeenLastCalledWith({ + type: 'INCREMENT2', + }) + expect((api3 as any).dispatch).toHaveBeenLastCalledWith({ + type: 'INCREMENT3', + }) + }) + + it('does not throw for unsupported payload, connections are isolated from each other', async () => { + const { devtools, create } = await getImports() + const options1 = { testConnectionId: 'asdf' } + const options2 = { testConnectionId: '2f' } + const options3 = { testConnectionId: 'd2e' } + const initialState1 = { count: 0 } + const initialState2 = { count: 2 } + const initialState3 = { count: 5 } + const api1 = create( + devtools(() => initialState1, { enabled: true, ...options1 }) + ) + const api2 = create( + devtools(() => initialState2, { enabled: true, ...options2 }) + ) + const api3 = create( + devtools(() => initialState3, { enabled: true, ...options3 }) + ) + ;(api1 as any).dispatch = jest.fn() + ;(api1 as any).dispatchFromDevtools = true + ;(api2 as any).dispatch = jest.fn() + ;(api2 as any).dispatchFromDevtools = true + ;(api3 as any).dispatch = jest.fn() + ;(api3 as any).dispatchFromDevtools = true + const setState1 = jest.spyOn(api1, 'setState') + const setState2 = jest.spyOn(api2, 'setState') + const setState3 = jest.spyOn(api3, 'setState') + const originalConsoleError = console.error + console.error = jest.fn() + + const [ + connectionSubscriber1, + connectionSubscriber2, + connectionSubscriber3, + ] = getUnnamedConnectionSubscribers( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + expect(() => { + connectionSubscriber1({ + type: 'ACTION', + payload: 'this.increment1()', + }) + }).not.toThrow() + expect(console.error).toHaveBeenNthCalledWith( + 1, + '[zustand devtools middleware] Could not parse the received json', + (() => { + try { + JSON.parse('this.increment1()') + } catch (e) { + return e + } + })() + ) + + expect(() => { + connectionSubscriber1({ + type: 'ACTION', + payload: 'this.increment2()', + }) + }).not.toThrow() + expect(console.error).toHaveBeenNthCalledWith( + 2, + '[zustand devtools middleware] Could not parse the received json', + (() => { + try { + JSON.parse('this.increment2()') + } catch (e) { + return e + } + })() + ) + + expect(() => { + connectionSubscriber1({ + type: 'ACTION', + payload: 'this.increment3()', + }) + }).not.toThrow() + expect(console.error).toHaveBeenNthCalledWith( + 3, + '[zustand devtools middleware] Could not parse the received json', + (() => { + try { + JSON.parse('this.increment3()') + } catch (e) { + return e + } + })() + ) + + expect(() => { + connectionSubscriber1({ + type: 'ACTION', + payload: { name: 'increment', args: [] }, + }) + }).not.toThrow() + expect(console.error).toHaveBeenNthCalledWith( + 4, + '[zustand devtools middleware] Unsupported action format' + ) + expect(() => { + connectionSubscriber2({ + type: 'ACTION', + payload: { name: 'increment', args: [] }, + }) + }).not.toThrow() + expect(console.error).toHaveBeenNthCalledWith( + 5, + '[zustand devtools middleware] Unsupported action format' + ) + expect(() => { + connectionSubscriber3({ + type: 'ACTION', + payload: { name: 'increment', args: [] }, + }) + }).not.toThrow() + expect(console.error).toHaveBeenNthCalledWith( + 6, + '[zustand devtools middleware] Unsupported action format' + ) + + expect(api1.getState()).toBe(initialState1) + expect(api2.getState()).toBe(initialState2) + expect(api3.getState()).toBe(initialState3) + expect(setState1).not.toBeCalled() + expect(setState2).not.toBeCalled() + expect(setState3).not.toBeCalled() + expect((api1 as any).dispatch).not.toBeCalled() + expect((api2 as any).dispatch).not.toBeCalled() + expect((api3 as any).dispatch).not.toBeCalled() + + console.error = originalConsoleError + }) + }) + + describe('DISPATCH and payload of type...', () => { + it('RESET, it inits with initial state, connections are isolated from each other', async () => { + const { devtools, create } = await getImports() + const options1 = { testConnectionId: 'asdf' } + const options2 = { testConnectionId: '2f' } + const options3 = { testConnectionId: 'd2e' } + const initialState1 = { count: 0 } + const initialState2 = { count: 2 } + const initialState3 = { count: 5 } + const api1 = create( + devtools(() => initialState1, { enabled: true, ...options1 }) + ) + const api2 = create( + devtools(() => initialState2, { enabled: true, ...options2 }) + ) + const api3 = create( + devtools(() => initialState3, { enabled: true, ...options3 }) + ) + api1.setState({ count: 1 }) + api2.setState({ count: 3 }) + api3.setState({ count: 10 }) + + const connections = getUnnamedConnectionApis( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + const [connection1, connection2, connection3] = connections + connections.forEach((conn) => conn.send.mockClear()) + const subscribers = getUnnamedConnectionSubscribers( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + const action = { + type: 'DISPATCH', + payload: { type: 'RESET' }, + } + subscribers.forEach((sub) => sub(action)) + + expect(api1.getState()).toStrictEqual(initialState1) + expect(api1.getState()).toStrictEqual(initialState1) + expect(api1.getState()).toStrictEqual(initialState1) + expect(connection1.init).toHaveBeenLastCalledWith(initialState1) + expect(connection2.init).toHaveBeenLastCalledWith(initialState2) + expect(connection3.init).toHaveBeenLastCalledWith(initialState3) + connections.forEach((conn) => expect(conn.send).not.toBeCalled()) + }) + + it('COMMIT, it inits with current state, connections are isolated from each other', async () => { + const { devtools, create } = await getImports() + const options1 = { testConnectionId: 'asdf' } + const options2 = { testConnectionId: '2f' } + const options3 = { testConnectionId: 'd2e' } + const initialState1 = { count: 0 } + const initialState2 = { count: 2 } + const initialState3 = { count: 5 } + const api1 = create( + devtools(() => initialState1, { enabled: true, ...options1 }) + ) + const api2 = create( + devtools(() => initialState2, { enabled: true, ...options2 }) + ) + const api3 = create( + devtools(() => initialState3, { enabled: true, ...options3 }) + ) + api1.setState({ count: 1 }) + api2.setState({ count: 3 }) + api3.setState({ count: 10 }) + const currentState1 = api1.getState() + const currentState2 = api2.getState() + const currentState3 = api3.getState() + + const connections = getUnnamedConnectionApis( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + connections.forEach((conn) => conn.send.mockClear()) + const subscribers = getUnnamedConnectionSubscribers( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + const action = { + type: 'DISPATCH', + payload: { type: 'COMMIT' }, + } + subscribers.forEach((sub) => sub(action)) + + const [connection1, connection2, connection3] = connections + expect(connection1.init).toHaveBeenLastCalledWith(currentState1) + expect(connection2.init).toHaveBeenLastCalledWith(currentState2) + expect(connection3.init).toHaveBeenLastCalledWith(currentState3) + connections.forEach((conn) => expect(conn.send).not.toBeCalled()) + }) + }) + + describe('ROLLBACK...', () => { + it('it updates state without recording and inits with `message.state, connections are isolated from each other`', async () => { + const { devtools, create } = await getImports() + const options1 = { testConnectionId: 'asdf' } + const options2 = { testConnectionId: '2f' } + const options3 = { testConnectionId: 'd2e' } + const initialState1 = { count: 0, increment: () => {} } + const initialState2 = { count: 2, increment: () => {} } + const initialState3 = { count: 5, increment: () => {} } + const api1 = create( + devtools(() => initialState1, { enabled: true, ...options1 }) + ) + const api2 = create( + devtools(() => initialState2, { enabled: true, ...options2 }) + ) + const api3 = create( + devtools(() => initialState3, { enabled: true, ...options3 }) + ) + const newState1 = { foo: 'bar1' } + const newState2 = { foo: 'bar2' } + const newState3 = { foo: 'bar3' } + + const connections = getUnnamedConnectionApis( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + connections.forEach((conn) => conn.send.mockClear()) + const [ + connectionSubscriber1, + connectionSubscriber2, + connectionSubscriber3, + ] = getUnnamedConnectionSubscribers( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + connectionSubscriber1({ + type: 'DISPATCH', + payload: { type: 'ROLLBACK' }, + state: JSON.stringify(newState1), + }) + connectionSubscriber2({ + type: 'DISPATCH', + payload: { type: 'ROLLBACK' }, + state: JSON.stringify(newState2), + }) + connectionSubscriber3({ + type: 'DISPATCH', + payload: { type: 'ROLLBACK' }, + state: JSON.stringify(newState3), + }) + + expect(api1.getState()).toStrictEqual({ + ...initialState1, + ...newState1, + }) + expect(api2.getState()).toStrictEqual({ + ...initialState2, + ...newState2, + }) + expect(api3.getState()).toStrictEqual({ + ...initialState3, + ...newState3, + }) + const [connection1, connection2, connection3] = connections + expect(connection1.init).toHaveBeenLastCalledWith({ + ...initialState1, + ...newState1, + }) + expect(connection2.init).toHaveBeenLastCalledWith({ + ...initialState2, + ...newState2, + }) + expect(connection3.init).toHaveBeenLastCalledWith({ + ...initialState3, + ...newState3, + }) + connections.forEach((conn) => expect(conn.send).not.toBeCalled()) + }) + + it('does not throw for unparsable `message.state`, connections are isolated from each other', async () => { + const { devtools, create } = await getImports() + const increment1 = () => {} + const increment2 = () => {} + const increment3 = () => {} + const options1 = { testConnectionId: 'asdf' } + const options2 = { testConnectionId: '2f' } + const options3 = { testConnectionId: 'd2e' } + const initialState1 = { count: 0, increment: increment1 } + const initialState2 = { count: 2, increment: increment2 } + const initialState3 = { count: 5, increment: increment3 } + const api1 = create( + devtools(() => initialState1, { enabled: true, ...options1 }) + ) + const api2 = create( + devtools(() => initialState2, { enabled: true, ...options2 }) + ) + const api3 = create( + devtools(() => initialState3, { enabled: true, ...options3 }) + ) + const originalConsoleError = console.error + console.error = jest.fn() + + const connections = getUnnamedConnectionApis( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + connections.forEach((conn) => conn.init.mockClear()) + connections.forEach((conn) => conn.send.mockClear()) + const [ + connectionSubscriber1, + connectionSubscriber2, + connectionSubscriber3, + ] = getUnnamedConnectionSubscribers( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + connectionSubscriber1({ + 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 + } + })() + ) + connectionSubscriber2({ + type: 'DISPATCH', + payload: { type: 'ROLLBACK' }, + state: 'foobar1', + }) + expect(console.error).toHaveBeenLastCalledWith( + '[zustand devtools middleware] Could not parse the received json', + (() => { + try { + JSON.parse('foobar1') + } catch (e) { + return e + } + })() + ) + connectionSubscriber3({ + type: 'DISPATCH', + payload: { type: 'ROLLBACK' }, + state: 'foobar3', + }) + expect(console.error).toHaveBeenLastCalledWith( + '[zustand devtools middleware] Could not parse the received json', + (() => { + try { + JSON.parse('foobar3') + } catch (e) { + return e + } + })() + ) + + expect(api1.getState()).toBe(initialState1) + expect(api2.getState()).toBe(initialState2) + expect(api3.getState()).toBe(initialState3) + connections.forEach((conn) => { + expect(conn.init).not.toBeCalled() + expect(conn.send).not.toBeCalled() + }) + + console.error = originalConsoleError + }) + }) + + describe('JUMP_TO_STATE...', () => { + const increment1 = () => {} + const increment2 = () => {} + const increment3 = () => {} + + it('it updates state without recording with `message.state`, connections are isolated from each other', async () => { + const { devtools, create } = await getImports() + const options1 = { testConnectionId: 'asdf' } + const options2 = { testConnectionId: '2f' } + const options3 = { testConnectionId: 'd2e' } + const initialState1 = { count: 0, increment: increment1 } + const initialState2 = { count: 2, increment: increment2 } + const initialState3 = { count: 5, increment: increment3 } + const api1 = create( + devtools(() => initialState1, { enabled: true, ...options1 }) + ) + const api2 = create( + devtools(() => initialState2, { enabled: true, ...options2 }) + ) + const api3 = create( + devtools(() => initialState3, { enabled: true, ...options3 }) + ) + const newState1 = { foo: 'bar1' } + const newState2 = { foo: 'bar2' } + const newState3 = { foo: 'bar3' } + + const connections = getUnnamedConnectionApis( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + connections.forEach((conn) => conn.send.mockClear()) + const [ + connectionSubscriber1, + connectionSubscriber2, + connectionSubscriber3, + ] = getUnnamedConnectionSubscribers( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + connectionSubscriber1({ + type: 'DISPATCH', + payload: { type: 'JUMP_TO_STATE' }, + state: JSON.stringify(newState1), + }) + connectionSubscriber2({ + type: 'DISPATCH', + payload: { type: 'JUMP_TO_STATE' }, + state: JSON.stringify(newState2), + }) + connectionSubscriber3({ + type: 'DISPATCH', + payload: { type: 'JUMP_TO_STATE' }, + state: JSON.stringify(newState3), + }) + + expect(api1.getState()).toStrictEqual({ + ...initialState1, + ...newState1, + }) + expect(api2.getState()).toStrictEqual({ + ...initialState2, + ...newState2, + }) + expect(api3.getState()).toStrictEqual({ + ...initialState3, + ...newState3, + }) + connections.forEach((conn) => expect(conn.send).not.toBeCalled()) + }) + + it('does not throw for unparsable `message.state`, connections are isolated from each other', async () => { + const { devtools, create } = await getImports() + const options1 = { testConnectionId: 'asdf' } + const options2 = { testConnectionId: '2f' } + const options3 = { testConnectionId: 'd2e' } + const initialState1 = { count: 0, increment: increment1 } + const initialState2 = { count: 2, increment: increment2 } + const initialState3 = { count: 5, increment: increment3 } + const api1 = create( + devtools(() => initialState1, { enabled: true, ...options1 }) + ) + const api2 = create( + devtools(() => initialState2, { enabled: true, ...options2 }) + ) + const api3 = create( + devtools(() => initialState3, { enabled: true, ...options3 }) + ) + const originalConsoleError = console.error + console.error = jest.fn() + + const connections = getUnnamedConnectionApis( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + connections.forEach((conn) => conn.send.mockClear()) + const [ + connectionSubscriber1, + connectionSubscriber2, + connectionSubscriber3, + ] = getUnnamedConnectionSubscribers( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + + connectionSubscriber1({ + 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 + } + })() + ) + connectionSubscriber2({ + type: 'DISPATCH', + payload: { type: 'JUMP_TO_STATE' }, + state: 'foobar2', + }) + expect(console.error).toHaveBeenLastCalledWith( + '[zustand devtools middleware] Could not parse the received json', + (() => { + try { + JSON.parse('foobar2') + } catch (e) { + return e + } + })() + ) + connectionSubscriber3({ + type: 'DISPATCH', + payload: { type: 'JUMP_TO_STATE' }, + state: 'foobar3', + }) + expect(console.error).toHaveBeenLastCalledWith( + '[zustand devtools middleware] Could not parse the received json', + (() => { + try { + JSON.parse('foobar3') + } catch (e) { + return e + } + })() + ) + + expect(api1.getState()).toBe(initialState1) + expect(api2.getState()).toBe(initialState2) + expect(api3.getState()).toBe(initialState3) + connections.forEach((conn) => expect(conn.send).not.toBeCalled()) + + console.error = originalConsoleError + }) + }) + + describe('JUMP_TO_ACTION...', () => { + const increment1 = () => {} + const increment2 = () => {} + const increment3 = () => {} + + it('it updates state without recording with `message.state`, connections are isolated from each other', async () => { + const { devtools, create } = await getImports() + const options1 = { testConnectionId: 'asdf' } + const options2 = { testConnectionId: '2f' } + const options3 = { testConnectionId: 'd2e' } + const initialState1 = { count: 0, increment: increment1 } + const initialState2 = { count: 2, increment: increment2 } + const initialState3 = { count: 5, increment: increment3 } + const api1 = create( + devtools(() => initialState1, { enabled: true, ...options1 }) + ) + const api2 = create( + devtools(() => initialState2, { enabled: true, ...options2 }) + ) + const api3 = create( + devtools(() => initialState3, { enabled: true, ...options3 }) + ) + const newState1 = { foo: 'bar1' } + const newState2 = { foo: 'bar2' } + const newState3 = { foo: 'bar3' } + + const connections = getUnnamedConnectionApis( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + connections.forEach((conn) => conn.send.mockClear()) + const [ + connectionSubscriber1, + connectionSubscriber2, + connectionSubscriber3, + ] = getUnnamedConnectionSubscribers( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + + connectionSubscriber1({ + type: 'DISPATCH', + payload: { type: 'JUMP_TO_ACTION' }, + state: JSON.stringify(newState1), + }) + connectionSubscriber2({ + type: 'DISPATCH', + payload: { type: 'JUMP_TO_ACTION' }, + state: JSON.stringify(newState2), + }) + connectionSubscriber3({ + type: 'DISPATCH', + payload: { type: 'JUMP_TO_ACTION' }, + state: JSON.stringify(newState3), + }) + + expect(api1.getState()).toStrictEqual({ + ...initialState1, + ...newState1, + }) + expect(api2.getState()).toStrictEqual({ + ...initialState2, + ...newState2, + }) + expect(api3.getState()).toStrictEqual({ + ...initialState3, + ...newState3, + }) + connections.forEach((conn) => expect(conn.send).not.toBeCalled()) + }) + + it('does not throw for unparsable `message.state`, connections are isolated from each other', async () => { + const { devtools, create } = await getImports() + const options1 = { testConnectionId: 'asdf' } + const options2 = { testConnectionId: '2f' } + const options3 = { testConnectionId: 'd2e' } + const initialState1 = { count: 0, increment: increment1 } + const initialState2 = { count: 2, increment: increment2 } + const initialState3 = { count: 5, increment: increment3 } + const api1 = create( + devtools(() => initialState1, { enabled: true, ...options1 }) + ) + const api2 = create( + devtools(() => initialState2, { enabled: true, ...options2 }) + ) + const api3 = create( + devtools(() => initialState3, { enabled: true, ...options3 }) + ) + const originalConsoleError = console.error + console.error = jest.fn() + + const connections = getUnnamedConnectionApis( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + connections.forEach((conn) => conn.send.mockClear()) + const [ + connectionSubscriber1, + connectionSubscriber2, + connectionSubscriber3, + ] = getUnnamedConnectionSubscribers( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + connectionSubscriber1({ + 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 + } + })() + ) + connectionSubscriber2({ + type: 'DISPATCH', + payload: { type: 'JUMP_TO_ACTION' }, + state: 'foobar2', + }) + expect(console.error).toHaveBeenLastCalledWith( + '[zustand devtools middleware] Could not parse the received json', + (() => { + try { + JSON.parse('foobar2') + } catch (e) { + return e + } + })() + ) + connectionSubscriber3({ + type: 'DISPATCH', + payload: { type: 'JUMP_TO_ACTION' }, + state: 'foobar3', + }) + expect(console.error).toHaveBeenLastCalledWith( + '[zustand devtools middleware] Could not parse the received json', + (() => { + try { + JSON.parse('foobar3') + } catch (e) { + return e + } + })() + ) + + expect(api1.getState()).toBe(initialState1) + expect(api2.getState()).toBe(initialState2) + expect(api3.getState()).toBe(initialState3) + connections.forEach((conn) => expect(conn.send).not.toBeCalled()) + + console.error = originalConsoleError + }) + + it('IMPORT_STATE, it updates state without recording and inits the last computedState, connections are isolated from each other', async () => { + const { devtools, create } = await getImports() + const options1 = { testConnectionId: 'asdf' } + const options2 = { testConnectionId: '2f' } + const options3 = { testConnectionId: 'd2e' } + const initialState1 = { count: 0, increment: increment1 } + const initialState2 = { count: 2, increment: increment2 } + const initialState3 = { count: 5, increment: increment3 } + const api1 = create( + devtools(() => initialState1, { enabled: true, ...options1 }) + ) + const api2 = create( + devtools(() => initialState2, { enabled: true, ...options2 }) + ) + const api3 = create( + devtools(() => initialState3, { enabled: true, ...options3 }) + ) + const nextLiftedState1 = { + computedStates: [{ state: { count: 4 } }, { state: { count: 5 } }], + } + const nextLiftedState2 = { + computedStates: [{ state: { count: 20 } }, { state: { count: 8 } }], + } + const nextLiftedState3 = { + computedStates: [{ state: { count: 12 } }, { state: { count: 100 } }], + } + + const connections = getUnnamedConnectionApis( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + connections.forEach((conn) => conn.send.mockClear()) + const [ + connectionSubscriber1, + connectionSubscriber2, + connectionSubscriber3, + ] = getUnnamedConnectionSubscribers( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + + connectionSubscriber1({ + type: 'DISPATCH', + payload: { + type: 'IMPORT_STATE', + nextLiftedState: nextLiftedState1, + }, + }) + connectionSubscriber2({ + type: 'DISPATCH', + payload: { + type: 'IMPORT_STATE', + nextLiftedState: nextLiftedState2, + }, + }) + connectionSubscriber3({ + type: 'DISPATCH', + payload: { + type: 'IMPORT_STATE', + nextLiftedState: nextLiftedState3, + }, + }) + + expect(api1.getState()).toStrictEqual({ + ...initialState1, + ...nextLiftedState1.computedStates.slice(-1)[0]?.state, + }) + expect(api2.getState()).toStrictEqual({ + ...initialState2, + ...nextLiftedState2.computedStates.slice(-1)[0]?.state, + }) + expect(api3.getState()).toStrictEqual({ + ...initialState3, + ...nextLiftedState3.computedStates.slice(-1)[0]?.state, + }) + const [connection1, connection2, connection3] = connections + expect(connection1.send).toHaveBeenLastCalledWith( + null, + nextLiftedState1 + ) + expect(connection2.send).toHaveBeenLastCalledWith( + null, + nextLiftedState2 + ) + expect(connection3.send).toHaveBeenLastCalledWith( + null, + nextLiftedState3 + ) + }) + + it('PAUSE_RECORDING, it toggles the sending of actions, connections are isolated from each other', async () => { + const { devtools, create } = await getImports() + const options1 = { testConnectionId: 'asdf' } + const options2 = { testConnectionId: '2f' } + const options3 = { testConnectionId: 'd2e' } + const api1 = create( + devtools(() => ({ count: 0 }), { enabled: true, ...options1 }) + ) + const api2 = create( + devtools(() => ({ count: 2 }), { enabled: true, ...options2 }) + ) + const api3 = create( + devtools(() => ({ count: 4 }), { enabled: true, ...options3 }) + ) + + const newState1 = { count: 1 } + const newState2 = { count: 12 } + const newState3 = { count: 30 } + api1.setState(newState1, false, 'increment') + api2.setState(newState2, false, 'increment') + api3.setState(newState3, false, 'increment') + + const [connection1, connection2, connection3] = + getUnnamedConnectionApis( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + const [ + connectionSubscriber1, + connectionSubscriber2, + connectionSubscriber3, + ] = getUnnamedConnectionSubscribers( + options1.testConnectionId, + options2.testConnectionId, + options3.testConnectionId + ) + + expect(connection1.send).toHaveBeenLastCalledWith( + { type: 'increment' }, + newState1 + ) + connectionSubscriber1({ + type: 'DISPATCH', + payload: { type: 'PAUSE_RECORDING' }, + }) + api1.setState({ count: 2 }, false, 'increment') + expect(connection1.send).toHaveBeenLastCalledWith( + { type: 'increment' }, + newState1 + ) + connectionSubscriber1({ + type: 'DISPATCH', + payload: { type: 'PAUSE_RECORDING' }, + }) + api1.setState({ count: 3 }, false, 'increment') + expect(connection1.send).toHaveBeenLastCalledWith( + { type: 'increment' }, + { count: 3 } + ) + + expect(connection2.send).toHaveBeenLastCalledWith( + { type: 'increment' }, + newState2 + ) + connectionSubscriber2({ + type: 'DISPATCH', + payload: { type: 'PAUSE_RECORDING' }, + }) + api2.setState({ count: 2 }, false, 'increment') + expect(connection2.send).toHaveBeenLastCalledWith( + { type: 'increment' }, + newState2 + ) + connectionSubscriber2({ + type: 'DISPATCH', + payload: { type: 'PAUSE_RECORDING' }, + }) + api2.setState({ count: 3 }, false, 'increment') + expect(connection2.send).toHaveBeenLastCalledWith( + { type: 'increment' }, + { count: 3 } + ) + + expect(connection3.send).toHaveBeenLastCalledWith( + { type: 'increment' }, + newState3 + ) + connectionSubscriber3({ + type: 'DISPATCH', + payload: { type: 'PAUSE_RECORDING' }, + }) + api3.setState({ count: 2 }, false, 'increment') + expect(connection3.send).toHaveBeenLastCalledWith( + { type: 'increment' }, + newState3 + ) + connectionSubscriber3({ + type: 'DISPATCH', + payload: { type: 'PAUSE_RECORDING' }, + }) + api3.setState({ count: 3 }, false, 'increment') + expect(connection3.send).toHaveBeenLastCalledWith( + { type: 'increment' }, + { count: 3 } + ) + }) + }) + }) +}) + +describe('when create devtools was called multiple times with `name` and `store` options defined', () => { + describe('when `type` was provided in store state methods as option', () => { + describe('When state changes...', () => { + it("sends { type: setStateName || 'anonymous`, ...rest } as the action with current state", async () => { + const { devtools, create } = await getImports() + const options = { + name: 'testOptionsName', + store: 'someStore', + enabled: true, + } + const api = create(devtools(() => ({ count: 0, foo: 'bar' }), options)) + + const testStateActionType = 'testSetStateName' + + api.setState({ count: 10 }, false, testStateActionType) + const [connection] = getNamedConnectionApis(options.name) + expect(connection.send).toHaveBeenLastCalledWith( + { type: `${options.store}/${testStateActionType}` }, + { [options.store]: { count: 10, foo: 'bar' } } + ) + + api.setState({ count: 15 }, false, { + type: testStateActionType, + payload: 15, + }) + expect(connection.send).toHaveBeenLastCalledWith( + { type: `${options.store}/${testStateActionType}`, payload: 15 }, + { [options.store]: { count: 15, foo: 'bar' } } + ) + + api.setState({ count: 5, foo: 'baz' }, true) + expect(connection.send).toHaveBeenLastCalledWith( + { type: `${options.store}/anonymous` }, + { [options.store]: { count: 5, foo: 'baz' } } + ) + }) + }) + + describe('when it receives a message of type...', () => { + describe('ACTION...', () => { + it('does nothing, connections isolated from each other', async () => { + const { devtools, create } = await getImports() + const options1 = { testConnectionId: '123', store: 'store1' } + const options2 = { testConnectionId: '231', store: 'store2' } + const initialState1 = { count: 0 } + const initialState2 = { count: 2 } + const initialState3 = { count: 5 } + const initialState4 = { count: 6 } + const api1 = create( + devtools(() => initialState1, { + enabled: true, + ...options1, + }) + ) + const api2 = create( + devtools(() => initialState2, { + enabled: true, + ...options1, + }) + ) + const api3 = create( + devtools(() => initialState3, { + enabled: true, + ...options2, + }) + ) + const api4 = create( + devtools(() => initialState4, { + enabled: true, + ...options2, + }) + ) + const setState1 = jest.spyOn(api1, 'setState') + const setState2 = jest.spyOn(api2, 'setState') + const setState3 = jest.spyOn(api3, 'setState') + const setState4 = jest.spyOn(api4, 'setState') + + const [subscriber] = getUnnamedConnectionSubscribers( + options1.testConnectionId + ) + subscriber({ + type: 'ACTION', + payload: '{ "type": "INCREMENT" }', + }) + + expect(api1.getState()).toBe(initialState1) + expect(api2.getState()).toBe(initialState2) + expect(api3.getState()).toBe(initialState3) + expect(api4.getState()).toBe(initialState4) + expect(setState1).not.toBeCalled() + expect(setState2).not.toBeCalled() + expect(setState3).not.toBeCalled() + expect(setState4).not.toBeCalled() + }) + + it('unless action type is __setState, connections isolated from each other', async () => { + const { devtools, create } = await getImports() + const name1 = 'name1' + const name2 = 'name2' + const store1 = 'someStore1' + const store2 = 'someStore2' + const options1 = { + name: name1, + store: store1, + testStore: store1, + } + const options2 = { + name: name2, + store: store2, + testStore: store2, + } + const initialState1 = { count: 0 } + const initialState2 = { count: 2 } + const api1 = create( + devtools(() => initialState1, { enabled: true, ...options1 }) + ) + const api2 = create( + devtools(() => initialState2, { enabled: true, ...options2 }) + ) + const originalConsoleError = console.error + console.error = jest.fn() + + const [connectionSubscriber] = getNamedConnectionSubscribers( + getKeyFromOptions(options1) + ) + connectionSubscriber({ + type: 'ACTION', + payload: + '{ "type": "__setState", "state": { "foo": "bar", "foo2": "bar2" } }', + }) + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining( + '[zustand devtools middleware] Unsupported __setState' + ) + ) + connectionSubscriber({ + type: 'ACTION', + payload: `{ "type": "__setState", "state": { "${options1.store}": { "foo": "bar" } } }`, + }) + + expect(console.error).toHaveBeenCalledTimes(1) + + expect(api1.getState()).toStrictEqual({ + ...initialState1, + foo: 'bar', + }) + expect(api2.getState()).toStrictEqual({ ...initialState2 }) + + console.error = originalConsoleError + }) + + it('does nothing even if there is `api.dispatch`, connections isolated from each other', async () => { + const { devtools, create } = await getImports() + const name1 = 'name1' + const name2 = 'name2' + const store1 = 'someStore1' + const store2 = 'someStore2' + const options1 = { + name: name1, + store: store1, + testStore: store1, + } + const options2 = { + name: name2, + store: store2, + testStore: store2, + } + const initialState1 = { count: 0 } + const initialState2 = { count: 2 } + const api1 = create( + devtools(() => initialState1, { enabled: true, ...options1 }) + ) + const api2 = create( + devtools(() => initialState2, { enabled: true, ...options2 }) + ) + ;(api1 as any).dispatch = jest.fn() + ;(api2 as any).dispatch = jest.fn() + const setState1 = jest.spyOn(api1, 'setState') + const setState2 = jest.spyOn(api2, 'setState') + + const subscribers = getNamedConnectionSubscribers( + getKeyFromOptions(options1), + getKeyFromOptions(options2) + ) + const testPayload = { + type: 'ACTION', + payload: '{ "type": "INCREMENT" }', + } + subscribers.forEach((sub) => sub(testPayload)) + + expect(api1.getState()).toBe(initialState1) + expect(api2.getState()).toBe(initialState2) + expect(setState1).not.toBeCalled() + expect(setState2).not.toBeCalled() + expect((api1 as any).dispatch).not.toBeCalled() + expect((api2 as any).dispatch).not.toBeCalled() + }) + + it('dispatches with `api.dispatch` when `api.dispatchFromDevtools` is set to true, connections are isolated from each other', async () => { + const { devtools, create } = await getImports() + const name1 = 'name1' + const name2 = 'name2' + const store1 = 'someStore1' + const store2 = 'someStore2' + const options1 = { + name: name1, + store: store1, + testStore: store1, + } + const options2 = { + name: name2, + store: store2, + testStore: store2, + } + const initialState1 = { count: 0 } + const initialState2 = { count: 2 } + const api1 = create( + devtools(() => initialState1, { enabled: true, ...options1 }) + ) + const api2 = create( + devtools(() => initialState2, { enabled: true, ...options2 }) + ) + ;(api1 as any).dispatch = jest.fn() + ;(api1 as any).dispatchFromDevtools = true + ;(api2 as any).dispatch = jest.fn() + ;(api2 as any).dispatchFromDevtools = true + const setState1 = jest.spyOn(api1, 'setState') + const setState2 = jest.spyOn(api2, 'setState') + + const subscribers = getNamedConnectionSubscribers( + getKeyFromOptions(options1), + getKeyFromOptions(options2) + ) + const getTestPayload = (n: number) => ({ + type: 'ACTION', + payload: `{ "type": "INCREMENT${n}" }`, + }) + subscribers.forEach((sub, i) => sub(getTestPayload(i + 1))) + + expect(api1.getState()).toBe(initialState1) + expect(api2.getState()).toBe(initialState2) + expect(setState1).not.toBeCalled() + expect(setState2).not.toBeCalled() + expect((api1 as any).dispatch).toHaveBeenLastCalledWith({ + type: 'INCREMENT1', + }) + expect((api2 as any).dispatch).toHaveBeenLastCalledWith({ + type: 'INCREMENT2', + }) + }) + }) + }) + }) }) diff --git a/tsconfig.json b/tsconfig.json index c025f695f..395750646 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "esnext", + "module": "esnext", "strict": true, "jsx": "react-jsx", "esModuleInterop": true,