dev-react-microstore 4.0.1 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +208 -142
- package/dist/index.d.mts +129 -2
- package/dist/index.d.ts +129 -2
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/example/README.md +54 -0
- package/example/eslint.config.js +28 -0
- package/example/index.html +13 -0
- package/example/package-lock.json +3382 -0
- package/example/package.json +29 -0
- package/example/public/index.html +98 -0
- package/example/public/vite.svg +1 -0
- package/example/src/App.css +613 -0
- package/example/src/App.tsx +34 -0
- package/example/src/assets/react.svg +1 -0
- package/example/src/components/Counter.tsx +112 -0
- package/example/src/components/CustomCompare.tsx +466 -0
- package/example/src/components/Logs.tsx +28 -0
- package/example/src/components/Search.tsx +38 -0
- package/example/src/components/ThemeToggle.tsx +25 -0
- package/example/src/components/TodoList.tsx +63 -0
- package/example/src/components/UserManager.tsx +68 -0
- package/example/src/index.css +68 -0
- package/example/src/main.tsx +10 -0
- package/example/src/store.ts +223 -0
- package/example/src/vite-env.d.ts +1 -0
- package/example/tsconfig.app.json +26 -0
- package/example/tsconfig.json +7 -0
- package/example/tsconfig.node.json +25 -0
- package/example/vite.config.ts +7 -0
- package/package.json +22 -5
- package/src/hooks.test.tsx +271 -0
- package/src/index.ts +514 -87
- package/src/store.test.ts +997 -0
- package/src/types.test.ts +161 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
3
|
+
import { renderHook, act } from '@testing-library/react'
|
|
4
|
+
import {
|
|
5
|
+
createStoreState,
|
|
6
|
+
useStoreSelector,
|
|
7
|
+
createSelectorHook,
|
|
8
|
+
} from './index'
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// useStoreSelector
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
describe('useStoreSelector', () => {
|
|
14
|
+
it('returns selected keys from the store', () => {
|
|
15
|
+
const store = createStoreState({ a: 1, b: 2, c: 3 })
|
|
16
|
+
const { result } = renderHook(() => useStoreSelector(store, ['a', 'c']))
|
|
17
|
+
|
|
18
|
+
expect(result.current).toEqual({ a: 1, c: 3 })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('re-renders when a subscribed key changes', () => {
|
|
22
|
+
const store = createStoreState({ count: 0 })
|
|
23
|
+
const { result } = renderHook(() => useStoreSelector(store, ['count']))
|
|
24
|
+
|
|
25
|
+
expect(result.current.count).toBe(0)
|
|
26
|
+
|
|
27
|
+
act(() => store.set({ count: 5 }))
|
|
28
|
+
|
|
29
|
+
expect(result.current.count).toBe(5)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('does not re-render when an unrelated key changes', () => {
|
|
33
|
+
const store = createStoreState({ a: 1, b: 2 })
|
|
34
|
+
const renderCount = vi.fn()
|
|
35
|
+
|
|
36
|
+
renderHook(() => {
|
|
37
|
+
renderCount()
|
|
38
|
+
return useStoreSelector(store, ['a'])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const callsAfterMount = renderCount.mock.calls.length
|
|
42
|
+
|
|
43
|
+
act(() => store.set({ b: 99 }))
|
|
44
|
+
|
|
45
|
+
expect(renderCount.mock.calls.length).toBe(callsAfterMount)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('does not re-render when set to the same value', () => {
|
|
49
|
+
const store = createStoreState({ count: 0 })
|
|
50
|
+
const renderCount = vi.fn()
|
|
51
|
+
|
|
52
|
+
renderHook(() => {
|
|
53
|
+
renderCount()
|
|
54
|
+
return useStoreSelector(store, ['count'])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const callsAfterMount = renderCount.mock.calls.length
|
|
58
|
+
|
|
59
|
+
act(() => store.set({ count: 0 }))
|
|
60
|
+
|
|
61
|
+
expect(renderCount.mock.calls.length).toBe(callsAfterMount)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('handles multiple keys and only re-renders for actual changes', () => {
|
|
65
|
+
const store = createStoreState({ x: 1, y: 2, z: 3 })
|
|
66
|
+
const { result } = renderHook(() => useStoreSelector(store, ['x', 'y']))
|
|
67
|
+
|
|
68
|
+
act(() => store.set({ x: 10 }))
|
|
69
|
+
expect(result.current).toEqual({ x: 10, y: 2 })
|
|
70
|
+
|
|
71
|
+
act(() => store.set({ y: 20 }))
|
|
72
|
+
expect(result.current).toEqual({ x: 10, y: 20 })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('works with custom comparison functions', () => {
|
|
76
|
+
const store = createStoreState({
|
|
77
|
+
items: [1, 2, 3],
|
|
78
|
+
})
|
|
79
|
+
const renderCount = vi.fn()
|
|
80
|
+
|
|
81
|
+
const { result } = renderHook(() => {
|
|
82
|
+
renderCount()
|
|
83
|
+
return useStoreSelector(store, [
|
|
84
|
+
{ items: (prev: number[], next: number[]) => prev.length === next.length },
|
|
85
|
+
])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const callsAfterMount = renderCount.mock.calls.length
|
|
89
|
+
|
|
90
|
+
// Same length array — custom compare returns true (equal), so no re-render
|
|
91
|
+
act(() => store.set({ items: [4, 5, 6] }))
|
|
92
|
+
expect(renderCount.mock.calls.length).toBe(callsAfterMount)
|
|
93
|
+
|
|
94
|
+
// Different length — custom compare returns false (not equal), triggers re-render
|
|
95
|
+
act(() => store.set({ items: [1, 2, 3, 4] }))
|
|
96
|
+
expect(result.current.items).toEqual([1, 2, 3, 4])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('returns referentially stable result when nothing changed', () => {
|
|
100
|
+
const store = createStoreState({ a: 1, b: 2 })
|
|
101
|
+
const { result, rerender } = renderHook(() => useStoreSelector(store, ['a']))
|
|
102
|
+
|
|
103
|
+
const first = result.current
|
|
104
|
+
rerender()
|
|
105
|
+
const second = result.current
|
|
106
|
+
|
|
107
|
+
expect(first).toBe(second)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('handles rapid sequential updates correctly', () => {
|
|
111
|
+
const store = createStoreState({ v: 0 })
|
|
112
|
+
const { result } = renderHook(() => useStoreSelector(store, ['v']))
|
|
113
|
+
|
|
114
|
+
act(() => {
|
|
115
|
+
store.set({ v: 1 })
|
|
116
|
+
store.set({ v: 2 })
|
|
117
|
+
store.set({ v: 3 })
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
expect(result.current.v).toBe(3)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('works with object values', () => {
|
|
124
|
+
const store = createStoreState({ user: { name: 'Alice', age: 30 } })
|
|
125
|
+
const { result } = renderHook(() => useStoreSelector(store, ['user']))
|
|
126
|
+
|
|
127
|
+
expect(result.current.user).toEqual({ name: 'Alice', age: 30 })
|
|
128
|
+
|
|
129
|
+
const newUser = { name: 'Bob', age: 25 }
|
|
130
|
+
act(() => store.set({ user: newUser }))
|
|
131
|
+
expect(result.current.user).toBe(newUser)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('works with null and undefined values', () => {
|
|
135
|
+
const store = createStoreState<{ v: string | null }>({ v: null })
|
|
136
|
+
const { result } = renderHook(() => useStoreSelector(store, ['v']))
|
|
137
|
+
|
|
138
|
+
expect(result.current.v).toBeNull()
|
|
139
|
+
|
|
140
|
+
act(() => store.set({ v: 'hello' }))
|
|
141
|
+
expect(result.current.v).toBe('hello')
|
|
142
|
+
|
|
143
|
+
act(() => store.set({ v: null }))
|
|
144
|
+
expect(result.current.v).toBeNull()
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// createSelectorHook
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
describe('createSelectorHook', () => {
|
|
152
|
+
it('returns a hook that works like useStoreSelector', () => {
|
|
153
|
+
const store = createStoreState({ count: 0, name: 'Alice' })
|
|
154
|
+
const useStore = createSelectorHook(store)
|
|
155
|
+
|
|
156
|
+
const { result } = renderHook(() => useStore(['count', 'name']))
|
|
157
|
+
expect(result.current).toEqual({ count: 0, name: 'Alice' })
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('re-renders on subscribed key change', () => {
|
|
161
|
+
const store = createStoreState({ count: 0 })
|
|
162
|
+
const useStore = createSelectorHook(store)
|
|
163
|
+
|
|
164
|
+
const { result } = renderHook(() => useStore(['count']))
|
|
165
|
+
|
|
166
|
+
act(() => store.set({ count: 42 }))
|
|
167
|
+
expect(result.current.count).toBe(42)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('does not re-render on unrelated key change', () => {
|
|
171
|
+
const store = createStoreState({ a: 1, b: 2 })
|
|
172
|
+
const useStore = createSelectorHook(store)
|
|
173
|
+
const renderCount = vi.fn()
|
|
174
|
+
|
|
175
|
+
renderHook(() => {
|
|
176
|
+
renderCount()
|
|
177
|
+
return useStore(['a'])
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const callsAfterMount = renderCount.mock.calls.length
|
|
181
|
+
|
|
182
|
+
act(() => store.set({ b: 99 }))
|
|
183
|
+
|
|
184
|
+
expect(renderCount.mock.calls.length).toBe(callsAfterMount)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('supports custom comparison functions', () => {
|
|
188
|
+
const store = createStoreState({ data: [1, 2] })
|
|
189
|
+
const useStore = createSelectorHook(store)
|
|
190
|
+
const renderCount = vi.fn()
|
|
191
|
+
|
|
192
|
+
renderHook(() => {
|
|
193
|
+
renderCount()
|
|
194
|
+
return useStore([
|
|
195
|
+
{ data: (prev: number[], next: number[]) => prev.length === next.length },
|
|
196
|
+
])
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const callsAfterMount = renderCount.mock.calls.length
|
|
200
|
+
|
|
201
|
+
act(() => store.set({ data: [3, 4] })) // same length
|
|
202
|
+
expect(renderCount.mock.calls.length).toBe(callsAfterMount)
|
|
203
|
+
|
|
204
|
+
act(() => store.set({ data: [1] })) // different length
|
|
205
|
+
expect(renderCount.mock.calls.length).toBeGreaterThan(callsAfterMount)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('multiple hooks on the same store work independently', () => {
|
|
209
|
+
const store = createStoreState({ a: 1, b: 2 })
|
|
210
|
+
const useStore = createSelectorHook(store)
|
|
211
|
+
|
|
212
|
+
const { result: r1 } = renderHook(() => useStore(['a']))
|
|
213
|
+
const { result: r2 } = renderHook(() => useStore(['b']))
|
|
214
|
+
|
|
215
|
+
act(() => store.set({ a: 10 }))
|
|
216
|
+
|
|
217
|
+
expect(r1.current.a).toBe(10)
|
|
218
|
+
expect(r2.current.b).toBe(2)
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Edge cases
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
describe('edge cases', () => {
|
|
226
|
+
it('useStoreSelector with middleware that blocks — hook reflects blocked state', () => {
|
|
227
|
+
const store = createStoreState({ count: 0 })
|
|
228
|
+
store.addMiddleware((_state, update, next) => {
|
|
229
|
+
if (update.count !== undefined && update.count < 0) return // block negatives
|
|
230
|
+
next()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
const { result } = renderHook(() => useStoreSelector(store, ['count']))
|
|
234
|
+
|
|
235
|
+
act(() => store.set({ count: 5 }))
|
|
236
|
+
expect(result.current.count).toBe(5)
|
|
237
|
+
|
|
238
|
+
act(() => store.set({ count: -1 })) // blocked
|
|
239
|
+
expect(result.current.count).toBe(5)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('useStoreSelector with middleware that transforms', () => {
|
|
243
|
+
const store = createStoreState({ name: '' })
|
|
244
|
+
store.addMiddleware((_state, update, next) => {
|
|
245
|
+
if (update.name) next({ name: update.name.trim() })
|
|
246
|
+
else next()
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
const { result } = renderHook(() => useStoreSelector(store, ['name']))
|
|
250
|
+
|
|
251
|
+
act(() => store.set({ name: ' hello ' }))
|
|
252
|
+
expect(result.current.name).toBe('hello')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('store works correctly after many subscribe/unsubscribe cycles', () => {
|
|
256
|
+
const store = createStoreState({ v: 0 })
|
|
257
|
+
const unsubs: (() => void)[] = []
|
|
258
|
+
|
|
259
|
+
for (let i = 0; i < 100; i++) {
|
|
260
|
+
unsubs.push(store.subscribe(['v'], () => {}))
|
|
261
|
+
}
|
|
262
|
+
for (const unsub of unsubs) {
|
|
263
|
+
unsub()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const listener = vi.fn()
|
|
267
|
+
store.subscribe(['v'], listener)
|
|
268
|
+
store.set({ v: 1 })
|
|
269
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
270
|
+
})
|
|
271
|
+
})
|