Skip to content

Commit

Permalink
feat: implement store.subscribeWithSelector
Browse files Browse the repository at this point in the history
  • Loading branch information
CharlesMangwa committed Feb 5, 2022
1 parent 268f5b6 commit dc019b6
Show file tree
Hide file tree
Showing 5 changed files with 150 additions and 5 deletions.
54 changes: 50 additions & 4 deletions src/__tests__/create-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { ZfyMiddlewareType } from '../types'
import { data, SyncStorage, rehydratedData, assertStoreContent } from '.'
import createStore from '../core/create-store'

type StoresDataType = { jest: typeof data }

describe('🐣 Core > createStore():', () => {
it('validates config', () => {
// @ts-expect-error
Expand All @@ -28,7 +30,7 @@ describe('🐣 Core > createStore():', () => {
expect.assertions(4)
})

it('works with log middleware enabled', () => {
it('works with logger middleware enabled', () => {
const consoleGroup = jest.spyOn(console, 'group').mockImplementation()
const consoleDebug = jest.spyOn(console, 'debug').mockImplementation()

Expand Down Expand Up @@ -69,6 +71,37 @@ describe('🐣 Core > createStore():', () => {
consoleDebug.mockRestore()
})

it('works with subscribe middleware enabled', () => {
const listener = jest.fn()

// @ts-expect-error
expect(() => createStore('jest', data, { subscribe: 1 })).toThrow(
"You need to provide a boolean to jest's createStore() options.subscribe, 1 is not a boolean."
)

const store = createStore<StoresDataType, 'jest'>('jest', data, {
subscribe: true,
})
const unsubscribe = store.subscribeWithSelector?.(
(state) => state.data.file,
listener,
{ fireImmediately: true }
)

expect(unsubscribe).toBeInstanceOf(Function)
expect(listener).toHaveBeenCalledWith(data.file, data.file)

store.getState().update((state) => {
state.file = rehydratedData.file
})

expect(listener).toHaveBeenCalledWith(rehydratedData.file, data.file)

unsubscribe?.()

expect.assertions(4)
})

it('works with persist middleware enabled', () => {
// @ts-expect-error
expect(() => createStore('jest', data, { persist: true })).toThrow(
Expand All @@ -88,20 +121,29 @@ describe('🐣 Core > createStore():', () => {
})

it('works with all provided middlewares enabled', () => {
const listener = jest.fn()
const consoleGroup = jest.spyOn(console, 'group').mockImplementation()
const consoleDebug = jest.spyOn(console, 'debug').mockImplementation()

const store = createStore('jest', data, {
const store = createStore<StoresDataType, 'jest'>('jest', data, {
persist: { getStorage: () => SyncStorage },
subscribe: true,
log: true,
})
const unsubscribe = store.subscribeWithSelector?.(
(state) => state.data.file,
listener
)

expect(unsubscribe).toBeInstanceOf(Function)
assertStoreContent({ store, expectedData: rehydratedData })

store.getState().update((state) => {
state.file = data.file
})

expect(listener).toHaveBeenCalledWith(data.file, rehydratedData.file)

expect(consoleGroup).toHaveBeenCalledWith(
'%c🗂 JEST STORE UPDATED',
'font-weight:bold'
Expand All @@ -126,14 +168,18 @@ describe('🐣 Core > createStore():', () => {
state.file = rehydratedData.file
})

expect.assertions(8)
expect(listener).toHaveBeenCalledWith(rehydratedData.file, data.file)

unsubscribe?.()

expect.assertions(11)
consoleGroup.mockRestore()
consoleDebug.mockRestore()
})

it('works with customMiddlewares provided', () => {
const fn = jest.fn()
const customMiddleware: ZfyMiddlewareType<{ jest: typeof data }, 'jest'> =
const customMiddleware: ZfyMiddlewareType<StoresDataType, 'jest'> =
(store, config) => (set, get, api) =>
config(
(args) => {
Expand Down
7 changes: 7 additions & 0 deletions src/internals/middlewares/create-middlewares.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {

import logger from './logger-middleware'
import persist from './persist-middleware'
import subscribe from './subscribe-middleware'

import { validateOptionsForPersistence } from '../validations'

Expand All @@ -29,6 +30,12 @@ const createMiddleware = <
if (options?.log) {
middlewares = [...middlewares, logger]
}
if (options?.subscribe) {
middlewares = [
...middlewares,
subscribe as unknown as ZfyMiddlewareType<StoresDataType, StoreNameType>,
]
}
if (options && 'persist' in options) {
validateOptionsForPersistence(storeName, options)
middlewares = [
Expand Down
77 changes: 77 additions & 0 deletions src/internals/middlewares/subscribe-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type {
EqualityChecker,
StateListener,
StateSelector,
StateSliceListener,
StoreApi,
} from 'zustand'
import type { StoreApiWithSubscribeWithSelector } from 'zustand/middleware'

import type {
StoreType,
CreateStoreConfigType,
CreateStoreOptionsType,
} from '../../types'

// NOTE: Adapted from https://github.com/pmndrs/zustand/blob/main/src/middleware/subscribeWithSelector.ts.
const middleware =
<
StoresDataType extends Record<string, any>,
StoreNameType extends keyof StoresDataType
>(
_: StoreNameType,
config: CreateStoreConfigType<StoresDataType, StoreNameType>,
__?: CreateStoreOptionsType<StoresDataType, StoreNameType>
): CreateStoreConfigType<
StoresDataType,
StoreNameType,
StoreApi<StoreType<StoresDataType, StoreNameType>> & {
subscribeWithSelector: StoreApiWithSubscribeWithSelector<
StoreType<StoresDataType, StoreNameType>
>['subscribe']
}
> =>
(set, get, api): StoreType<StoresDataType, StoreNameType> => {
const deprecatedSubscribe = api.subscribe

// @ts-expect-error FIXME: Deprecated subscribe signature missing.
api.subscribeWithSelector = <StateSlice>(
selector: StateSelector<
StoreType<StoresDataType, StoreNameType>,
StateSlice
>,
providedListener: StateSliceListener<StateSlice>,
options?:
| {
equalityFn?: EqualityChecker<StateSlice>
fireImmediately?: boolean
}
| undefined
) => {
let listener: StateListener<StoreType<StoresDataType, StoreNameType>> =
selector

if (providedListener) {
const equalityFn = options?.equalityFn || Object.is
let currentSlice = selector(api.getState())

listener = (state) => {
const nextSlice = selector(state)
if (!equalityFn(currentSlice, nextSlice)) {
const previousSlice = currentSlice
providedListener((currentSlice = nextSlice), previousSlice)
}
}

if (options?.fireImmediately) {
providedListener(currentSlice, currentSlice)
}
}

return deprecatedSubscribe(listener)
}

return config(set, get, api)
}

export default middleware
7 changes: 7 additions & 0 deletions src/internals/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ export function validateCreateStore<
`You need to provide a boolean to ${storeName}'s createStore() options.log, ${options?.log} is not a boolean.`
)

invariant(
!options ||
(typeof options === 'object' && options.subscribe === undefined) ||
typeof options.subscribe === 'boolean',
`You need to provide a boolean to ${storeName}'s createStore() options.subscribe, ${options?.subscribe} is not a boolean.`
)

if (options?.persist) {
validateOptionsForPersistence(storeName, options)
}
Expand Down
10 changes: 9 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import type {
UseBoundStore,
EqualityChecker,
} from 'zustand'
import type { PersistOptions, StoreApiWithPersist } from 'zustand/middleware'
import type {
PersistOptions,
StoreApiWithPersist,
StoreApiWithSubscribeWithSelector,
} from 'zustand/middleware'

export type ZfyMiddlewareType<
StoresDataType extends Record<string, any>,
Expand Down Expand Up @@ -51,6 +55,7 @@ export interface CreateStoreOptionsType<
StoreNameType extends keyof StoresDataType
> {
log?: boolean
subscribe?: boolean
persist?: Omit<
PersistOptions<StoreType<StoresDataType, StoreNameType>>,
'name' | 'blacklist' | 'whitelist'
Expand All @@ -71,6 +76,9 @@ export type CreateStoreType<
persist?: StoreApiWithPersist<
StoreType<StoresDataType, StoreNameType>
>['persist']
subscribeWithSelector?: StoreApiWithSubscribeWithSelector<
StoreType<StoresDataType, StoreNameType>
>['subscribe']
}

export type InitStoresResetOptionsType<
Expand Down

0 comments on commit dc019b6

Please sign in to comment.