Skip to content

Commit 16ffa43

Browse files
committed
useStoreListener types and tests
1 parent 529e439 commit 16ffa43

File tree

3 files changed

+149
-5
lines changed

3 files changed

+149
-5
lines changed

index.d.ts

+24
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,30 @@ export function useStore<
4040
? Pick<StoreValue<SomeStore>, Key>
4141
: StoreValue<SomeStore>
4242

43+
type Listener<SomeStore extends Store> = (
44+
value: StoreValue<SomeStore>,
45+
changed?: SomeStore extends MapStore ? StoreValue<SomeStore> : never
46+
) => void
47+
48+
export interface UseStoreListenerOptions<
49+
SomeStore extends Store,
50+
Key extends string | number | symbol
51+
> extends UseStoreOptions<SomeStore, Key> {
52+
leading?: boolean
53+
listener: Listener<SomeStore>
54+
}
55+
56+
/**
57+
* Subscribe to store changes to trigger an effect
58+
*
59+
* @param store Store instance.
60+
* @returns null
61+
*/
62+
export function useStoreListener<
63+
SomeStore extends Store,
64+
Key extends keyof StoreValue<Store>
65+
>(store: SomeStore, options: UseStoreListenerOptions<SomeStore, Key>): null
66+
4367
/**
4468
* Batch React updates. It is just wrap for React’s `unstable_batchedUpdates`
4569
* with fix for React Native.

index.js

+3
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ export function useStoreListener(store, opts = {}) {
4848

4949
React.useEffect(() => {
5050
let listener = (value, changed) => listenerRef.current(value, changed)
51+
if (opts.leading) {
52+
listener(store.get(), null)
53+
}
5154
if (opts.keys) {
5255
return listenKeys(store, opts.keys, listener)
5356
} else {

index.test.ts

+122-5
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ import {
44
mapTemplate,
55
onMount,
66
atom,
7-
map
7+
map,
8+
MapStore
89
} from 'nanostores'
910
import React, { FC } from 'react'
1011
import ReactTesting from '@testing-library/react'
1112
import { delay } from 'nanodelay'
1213

13-
import { useStore } from './index.js'
14+
import { useStore, useStoreListener } from './index.js'
1415

1516
let { render, screen, act } = ReactTesting
1617
let { createElement: h, useState } = React
@@ -177,16 +178,16 @@ it('does not reload store on component changes', async () => {
177178
})
178179

179180
it('handles keys option', async () => {
180-
type MapStore = {
181+
type StoreValue = {
181182
a?: string
182183
b?: string
183184
}
184185
let Wrapper: FC = ({ children }) => h('div', {}, children)
185-
let mapStore = map<MapStore>()
186+
let mapStore = map<StoreValue>()
186187
let renderCount = 0
187188
let MapTest = (): React.ReactElement => {
188189
renderCount++
189-
let [keys, setKeys] = useState<(keyof MapStore)[]>(['a'])
190+
let [keys, setKeys] = useState<(keyof StoreValue)[]>(['a'])
190191
let { a, b } = useStore(mapStore, { keys })
191192
return h(
192193
'div',
@@ -245,3 +246,119 @@ it('handles keys option', async () => {
245246
expect(screen.getByTestId('map-test')).toHaveTextContent('map:a-b')
246247
expect(renderCount).toBe(4)
247248
})
249+
250+
describe('useStoreListener hook', () => {
251+
it('throws on template instead of store', () => {
252+
let Test = (): void => {}
253+
let [errors, Catcher] = getCatcher(() => {
254+
// @ts-expect-error
255+
useStoreListener(Test, { listener: () => {} })
256+
})
257+
render(h(Catcher))
258+
expect(errors).toEqual([
259+
'Use useStore(Template(id)) or useSync() ' +
260+
'from @logux/client/react for templates'
261+
])
262+
})
263+
264+
function createTest(opts = {}): {
265+
Test: FC
266+
stats: { renders: number; calls: number }
267+
store: MapStore
268+
} {
269+
let store = map({ a: 0 })
270+
let stats = { renders: 0, calls: 0 }
271+
let Test = (): React.ReactElement => {
272+
stats.renders += 1
273+
useStoreListener(store, {
274+
...opts,
275+
listener: () => {
276+
stats.calls += 1
277+
}
278+
})
279+
return h('span')
280+
}
281+
return { Test, stats, store }
282+
}
283+
284+
it('invokes provided callback on store change', async () => {
285+
let { Test, stats, store } = createTest()
286+
render(h(Test))
287+
await act(async () => {
288+
store.set({ a: 1 })
289+
await delay(1)
290+
})
291+
expect(stats.calls).toBe(1)
292+
})
293+
294+
it("doesn't trigger rerenders on store change, but invokes the callback", async () => {
295+
let { Test, stats, store } = createTest()
296+
render(h(Test))
297+
await act(async () => {
298+
store.set({ a: 1 })
299+
await delay(1)
300+
store.set({ a: 2 })
301+
await delay(1)
302+
})
303+
expect(stats.calls).toBe(2)
304+
expect(stats.renders).toBe(1)
305+
})
306+
307+
it('handles `leading` option', () => {
308+
let { Test, stats } = createTest({ leading: true })
309+
render(h(Test))
310+
expect(stats.calls).toBe(1)
311+
expect(stats.renders).toBe(1)
312+
})
313+
314+
it('handles `keys` option', async () => {
315+
let renders = 0
316+
let calls = 0
317+
type StoreValue = { a: number; b: number }
318+
let mapStore = map<StoreValue>({ a: 0, b: 0 })
319+
let MapTest = (): React.ReactElement => {
320+
renders += 1
321+
let [keys, setKeys] = useState<(keyof StoreValue)[]>(['a'])
322+
useStoreListener(mapStore, {
323+
keys,
324+
listener: () => {
325+
calls += 1
326+
}
327+
})
328+
return h(
329+
'div',
330+
{ 'data-testid': 'map-test' },
331+
h('button', {
332+
onClick: () => {
333+
setKeys(['a', 'b'])
334+
}
335+
}),
336+
null
337+
)
338+
}
339+
render(h(MapTest))
340+
await act(async () => {
341+
mapStore.setKey('a', 1)
342+
await delay(1)
343+
})
344+
expect(calls).toBe(1)
345+
expect(renders).toBe(1)
346+
347+
// does not react to 'b' key change
348+
await act(async () => {
349+
mapStore.setKey('b', 1)
350+
await delay(1)
351+
})
352+
expect(calls).toBe(1)
353+
expect(renders).toBe(1)
354+
355+
await act(async () => {
356+
screen.getByRole('button').click() // enable 'b' key
357+
await delay(1)
358+
mapStore.setKey('b', 2)
359+
await delay(1)
360+
})
361+
expect(calls).toBe(2)
362+
expect(renders).toBe(2) // due to `keys` state change inside the component
363+
})
364+
})

0 commit comments

Comments
 (0)