@storve/core 1.0.1 → 1.0.3
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/LICENSE +21 -0
- package/README.md +993 -26
- package/dist/adapters/indexedDB.cjs +0 -1
- package/dist/adapters/indexedDB.mjs +0 -1
- package/dist/adapters/localStorage.cjs +0 -1
- package/dist/adapters/localStorage.mjs +0 -1
- package/dist/adapters/memory.cjs +0 -1
- package/dist/adapters/memory.mjs +0 -1
- package/dist/adapters/sessionStorage.cjs +0 -1
- package/dist/adapters/sessionStorage.mjs +0 -1
- package/dist/async-entry.d.ts +0 -1
- package/dist/async.cjs +0 -1
- package/dist/async.d.ts +0 -1
- package/dist/async.mjs +0 -1
- package/dist/batch.d.ts +0 -1
- package/dist/compose.d.ts +0 -1
- package/dist/computed-entry.d.ts +0 -1
- package/dist/computed.cjs +0 -1
- package/dist/computed.d.ts +0 -1
- package/dist/computed.mjs +0 -1
- package/dist/devtools/history.d.ts +0 -1
- package/dist/devtools/index.d.ts +0 -1
- package/dist/devtools/redux-bridge.d.ts +0 -1
- package/dist/devtools/snapshots.d.ts +0 -1
- package/dist/devtools/withDevtools.d.ts +0 -1
- package/dist/devtools.cjs +0 -1
- package/dist/devtools.mjs +0 -1
- package/dist/extensions/noop.d.ts +0 -1
- package/dist/index.cjs +0 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.mjs +0 -1
- package/dist/persist/adapters/indexedDB.d.ts +0 -1
- package/dist/persist/adapters/localStorage.d.ts +0 -1
- package/dist/persist/adapters/memory.d.ts +0 -1
- package/dist/persist/adapters/sessionStorage.d.ts +0 -1
- package/dist/persist/debounce.d.ts +0 -1
- package/dist/persist/hydrate.d.ts +0 -1
- package/dist/persist/index.d.ts +0 -1
- package/dist/persist/serialize.d.ts +0 -1
- package/dist/persist.cjs +0 -1
- package/dist/persist.mjs +0 -1
- package/dist/proxy.d.ts +0 -1
- package/dist/registry-qtr1UpFU.js +0 -1
- package/dist/registry-zaKZ1P-s.js +0 -1
- package/dist/registry.d.ts +0 -1
- package/dist/signals/createSignal.d.ts +0 -1
- package/dist/signals/index.d.ts +0 -1
- package/dist/signals/useSignal.d.ts +0 -1
- package/dist/signals.cjs +0 -1
- package/dist/signals.mjs +0 -1
- package/dist/store.d.ts +0 -1
- package/dist/sync/channel.d.ts +0 -1
- package/dist/sync/index.d.ts +0 -1
- package/dist/sync/protocol.d.ts +0 -1
- package/dist/sync/withSync.d.ts +0 -1
- package/dist/sync.cjs +0 -1
- package/dist/sync.mjs +0 -1
- package/dist/types.d.ts +0 -1
- package/package.json +9 -3
- package/CHANGELOG.md +0 -151
- package/benchmarks/run.ts +0 -102
- package/benchmarks/week2.md +0 -9
- package/benchmarks/week2.ts +0 -64
- package/benchmarks/week4.md +0 -13
- package/benchmarks/week4.ts +0 -178
- package/benchmarks/week5.md +0 -15
- package/benchmarks/week5.ts +0 -184
- package/coverage/coverage-summary.json +0 -31
- package/dist/adapters/indexedDB.cjs.map +0 -1
- package/dist/adapters/indexedDB.mjs.map +0 -1
- package/dist/adapters/localStorage.cjs.map +0 -1
- package/dist/adapters/localStorage.mjs.map +0 -1
- package/dist/adapters/memory.cjs.map +0 -1
- package/dist/adapters/memory.mjs.map +0 -1
- package/dist/adapters/sessionStorage.cjs.map +0 -1
- package/dist/adapters/sessionStorage.mjs.map +0 -1
- package/dist/async-entry.d.ts.map +0 -1
- package/dist/async.cjs.map +0 -1
- package/dist/async.d.ts.map +0 -1
- package/dist/async.mjs.map +0 -1
- package/dist/batch.d.ts.map +0 -1
- package/dist/compose.d.ts.map +0 -1
- package/dist/computed-entry.d.ts.map +0 -1
- package/dist/computed.cjs.map +0 -1
- package/dist/computed.d.ts.map +0 -1
- package/dist/computed.mjs.map +0 -1
- package/dist/devtools/history.d.ts.map +0 -1
- package/dist/devtools/index.d.ts.map +0 -1
- package/dist/devtools/redux-bridge.d.ts.map +0 -1
- package/dist/devtools/snapshots.d.ts.map +0 -1
- package/dist/devtools/withDevtools.d.ts.map +0 -1
- package/dist/devtools.cjs.map +0 -1
- package/dist/devtools.mjs.map +0 -1
- package/dist/extensions/noop.d.ts.map +0 -1
- package/dist/index.cjs.js +0 -118
- package/dist/index.cjs.js.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.esm.js +0 -116
- package/dist/index.esm.js.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/persist/adapters/indexedDB.d.ts.map +0 -1
- package/dist/persist/adapters/localStorage.d.ts.map +0 -1
- package/dist/persist/adapters/memory.d.ts.map +0 -1
- package/dist/persist/adapters/sessionStorage.d.ts.map +0 -1
- package/dist/persist/debounce.d.ts.map +0 -1
- package/dist/persist/hydrate.d.ts.map +0 -1
- package/dist/persist/index.d.ts.map +0 -1
- package/dist/persist/serialize.d.ts.map +0 -1
- package/dist/persist.cjs.map +0 -1
- package/dist/persist.mjs.map +0 -1
- package/dist/proxy.d.ts.map +0 -1
- package/dist/registry-D3X0HSbl.js +0 -26
- package/dist/registry-D3X0HSbl.js.map +0 -1
- package/dist/registry-RDjbeJdx.js +0 -29
- package/dist/registry-RDjbeJdx.js.map +0 -1
- package/dist/registry-qtr1UpFU.js.map +0 -1
- package/dist/registry-zaKZ1P-s.js.map +0 -1
- package/dist/registry.d.ts.map +0 -1
- package/dist/signals/createSignal.d.ts.map +0 -1
- package/dist/signals/index.d.ts.map +0 -1
- package/dist/signals/useSignal.d.ts.map +0 -1
- package/dist/signals.cjs.map +0 -1
- package/dist/signals.mjs.map +0 -1
- package/dist/stats.html +0 -4949
- package/dist/store.d.ts.map +0 -1
- package/dist/sync/channel.d.ts.map +0 -1
- package/dist/sync/index.d.ts.map +0 -1
- package/dist/sync/protocol.d.ts.map +0 -1
- package/dist/sync/withSync.d.ts.map +0 -1
- package/dist/sync.cjs.map +0 -1
- package/dist/sync.mjs.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/rollup.config.mjs +0 -44
- package/src/async-entry.ts +0 -6
- package/src/async.ts +0 -240
- package/src/batch.ts +0 -33
- package/src/compose.ts +0 -50
- package/src/computed-entry.ts +0 -6
- package/src/computed.ts +0 -187
- package/src/devtools/history.ts +0 -103
- package/src/devtools/index.ts +0 -5
- package/src/devtools/redux-bridge.ts +0 -70
- package/src/devtools/snapshots.ts +0 -54
- package/src/devtools/withDevtools.ts +0 -196
- package/src/extensions/noop.ts +0 -12
- package/src/index.ts +0 -4
- package/src/persist/adapters/indexedDB.ts +0 -114
- package/src/persist/adapters/localStorage.ts +0 -28
- package/src/persist/adapters/memory.ts +0 -26
- package/src/persist/adapters/sessionStorage.ts +0 -28
- package/src/persist/debounce.ts +0 -28
- package/src/persist/hydrate.ts +0 -60
- package/src/persist/index.ts +0 -141
- package/src/persist/serialize.ts +0 -60
- package/src/proxy.ts +0 -87
- package/src/registry.ts +0 -67
- package/src/signals/createSignal.ts +0 -81
- package/src/signals/index.ts +0 -20
- package/src/signals/useSignal.ts +0 -18
- package/src/store.ts +0 -250
- package/src/sync/channel.ts +0 -15
- package/src/sync/index.ts +0 -3
- package/src/sync/protocol.ts +0 -18
- package/src/sync/withSync.ts +0 -147
- package/src/types.ts +0 -159
- package/tests/async.test.ts +0 -1100
- package/tests/batch.test.ts +0 -41
- package/tests/compose.test.ts +0 -209
- package/tests/computed.test.ts +0 -867
- package/tests/devtools.test.ts +0 -1039
- package/tests/integration/persist.integration.test.ts +0 -258
- package/tests/integration/signals.integration.test.ts +0 -309
- package/tests/integration.test.ts +0 -278
- package/tests/persist/adapters/indexedDB.adapter.test.ts +0 -185
- package/tests/persist/adapters/localStorage.adapter.test.ts +0 -105
- package/tests/persist/adapters/memory.adapter.test.ts +0 -112
- package/tests/persist/adapters/sessionStorage.adapter.test.ts +0 -128
- package/tests/persist/debounce.test.ts +0 -121
- package/tests/persist/hydrate.test.ts +0 -120
- package/tests/persist/migrate.test.ts +0 -208
- package/tests/persist/persist.test.ts +0 -357
- package/tests/persist/serialize.test.ts +0 -128
- package/tests/proxy.test.ts +0 -473
- package/tests/registry.test.ts +0 -67
- package/tests/signals/derived.test.ts +0 -244
- package/tests/signals/inference.test.ts +0 -108
- package/tests/signals/signal.test.ts +0 -348
- package/tests/signals/useSignal.test.tsx +0 -275
- package/tests/store.test.ts +0 -482
- package/tests/stress.test.ts +0 -268
- package/tests/sync.test.ts +0 -576
- package/tests/types.test.ts +0 -32
- package/tests/v0.3.test.ts +0 -813
- package/tree-shake-test.js +0 -1
- package/tsconfig.json +0 -15
- package/vitest.config.ts +0 -22
- package/vitest_play.ts +0 -7
package/tests/async.test.ts
DELETED
|
@@ -1,1100 +0,0 @@
|
|
|
1
|
-
// packages/storve/tests/async.test.ts
|
|
2
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
3
|
-
import { createStore } from '../src'
|
|
4
|
-
import { createAsync } from '../src/async'
|
|
5
|
-
|
|
6
|
-
// ─────────────────────────────────────────────
|
|
7
|
-
// TEST UTILITIES
|
|
8
|
-
// ─────────────────────────────────────────────
|
|
9
|
-
|
|
10
|
-
const wait = (ms: number) => new Promise(r => setTimeout(r, ms))
|
|
11
|
-
|
|
12
|
-
// Controlled promise — lets tests resolve/reject on demand
|
|
13
|
-
function deferred<T>() {
|
|
14
|
-
let resolve!: (v: T) => void
|
|
15
|
-
let reject!: (e: unknown) => void
|
|
16
|
-
const promise = new Promise<T>((res, rej) => {
|
|
17
|
-
resolve = res
|
|
18
|
-
reject = rej
|
|
19
|
-
})
|
|
20
|
-
return { promise, resolve, reject }
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// ─────────────────────────────────────────────
|
|
24
|
-
// 1. INITIAL STATE
|
|
25
|
-
// ─────────────────────────────────────────────
|
|
26
|
-
describe('Async State — Initial State', () => {
|
|
27
|
-
|
|
28
|
-
it('async key has correct initial shape', () => {
|
|
29
|
-
const store = createStore({
|
|
30
|
-
user: createAsync(async () => ({ name: 'Alice' }))
|
|
31
|
-
})
|
|
32
|
-
const state = store.getState()
|
|
33
|
-
expect(state.user).toMatchObject({
|
|
34
|
-
data: null,
|
|
35
|
-
error: null,
|
|
36
|
-
status: 'idle',
|
|
37
|
-
loading: false,
|
|
38
|
-
})
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('loading is false before any fetch', () => {
|
|
42
|
-
const store = createStore({
|
|
43
|
-
user: createAsync(async () => ({ name: 'Alice' }))
|
|
44
|
-
})
|
|
45
|
-
expect(store.getState().user.loading).toBe(false)
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('data is null before any fetch', () => {
|
|
49
|
-
const store = createStore({
|
|
50
|
-
user: createAsync(async () => ({ name: 'Alice' }))
|
|
51
|
-
})
|
|
52
|
-
expect(store.getState().user.data).toBeNull()
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it('error is null before any fetch', () => {
|
|
56
|
-
const store = createStore({
|
|
57
|
-
user: createAsync(async () => ({ name: 'Alice' }))
|
|
58
|
-
})
|
|
59
|
-
expect(store.getState().user.error).toBeNull()
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
it('status is idle before any fetch', () => {
|
|
63
|
-
const store = createStore({
|
|
64
|
-
user: createAsync(async () => ({ name: 'Alice' }))
|
|
65
|
-
})
|
|
66
|
-
expect(store.getState().user.status).toBe('idle')
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
it('multiple async keys each have independent initial state', () => {
|
|
70
|
-
const store = createStore({
|
|
71
|
-
user: createAsync(async () => ({ name: 'Alice' })),
|
|
72
|
-
posts: createAsync(async () => [{ id: 1 }]),
|
|
73
|
-
})
|
|
74
|
-
expect(store.getState().user.status).toBe('idle')
|
|
75
|
-
expect(store.getState().posts.status).toBe('idle')
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
it('sync and async keys coexist in same store', () => {
|
|
79
|
-
const store = createStore({
|
|
80
|
-
theme: 'light',
|
|
81
|
-
count: 0,
|
|
82
|
-
user: createAsync(async () => ({ name: 'Alice' })),
|
|
83
|
-
})
|
|
84
|
-
expect(store.getState().theme).toBe('light')
|
|
85
|
-
expect(store.getState().count).toBe(0)
|
|
86
|
-
expect(store.getState().user.status).toBe('idle')
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
it('refetch function is exposed on initial async state', () => {
|
|
90
|
-
const store = createStore({
|
|
91
|
-
user: createAsync(async () => ({ name: 'Alice' }))
|
|
92
|
-
})
|
|
93
|
-
expect(typeof store.getState().user.refetch).toBe('function')
|
|
94
|
-
})
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
// ─────────────────────────────────────────────
|
|
98
|
-
// 2. STATUS TRANSITIONS
|
|
99
|
-
// ─────────────────────────────────────────────
|
|
100
|
-
describe('Async State — Status Transitions', () => {
|
|
101
|
-
|
|
102
|
-
describe('idle → loading → success', () => {
|
|
103
|
-
it('status transitions to loading when fetch starts', async () => {
|
|
104
|
-
const { promise, resolve } = deferred<{ name: string }>()
|
|
105
|
-
const store = createStore({
|
|
106
|
-
user: createAsync(() => promise)
|
|
107
|
-
})
|
|
108
|
-
const fetchPromise = store.fetch('user')
|
|
109
|
-
expect(store.getState().user.status).toBe('loading')
|
|
110
|
-
expect(store.getState().user.loading).toBe(true)
|
|
111
|
-
resolve({ name: 'Alice' })
|
|
112
|
-
await fetchPromise
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
it('status transitions to success on resolve', async () => {
|
|
116
|
-
const store = createStore({
|
|
117
|
-
user: createAsync(async () => ({ name: 'Alice' }))
|
|
118
|
-
})
|
|
119
|
-
await store.fetch('user')
|
|
120
|
-
expect(store.getState().user.status).toBe('success')
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
it('data is set correctly on success', async () => {
|
|
124
|
-
const store = createStore({
|
|
125
|
-
user: createAsync(async () => ({ name: 'Alice', age: 30 }))
|
|
126
|
-
})
|
|
127
|
-
await store.fetch('user')
|
|
128
|
-
expect(store.getState().user.data).toEqual({ name: 'Alice', age: 30 })
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
it('loading is false after success', async () => {
|
|
132
|
-
const store = createStore({
|
|
133
|
-
user: createAsync(async () => ({ name: 'Alice' }))
|
|
134
|
-
})
|
|
135
|
-
await store.fetch('user')
|
|
136
|
-
expect(store.getState().user.loading).toBe(false)
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
it('error is null after success', async () => {
|
|
140
|
-
const store = createStore({
|
|
141
|
-
user: createAsync(async () => ({ name: 'Alice' }))
|
|
142
|
-
})
|
|
143
|
-
await store.fetch('user')
|
|
144
|
-
expect(store.getState().user.error).toBeNull()
|
|
145
|
-
})
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
describe('idle → loading → error', () => {
|
|
149
|
-
it('status transitions to error on rejection', async () => {
|
|
150
|
-
const store = createStore({
|
|
151
|
-
user: createAsync(async () => { throw new Error('Network error') })
|
|
152
|
-
})
|
|
153
|
-
await store.fetch('user').catch(() => { })
|
|
154
|
-
expect(store.getState().user.status).toBe('error')
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
it('error message is captured on failure', async () => {
|
|
158
|
-
const store = createStore({
|
|
159
|
-
user: createAsync(async () => { throw new Error('Network error') })
|
|
160
|
-
})
|
|
161
|
-
await store.fetch('user').catch(() => { })
|
|
162
|
-
expect(store.getState().user.error).toBe('Network error')
|
|
163
|
-
})
|
|
164
|
-
|
|
165
|
-
it('loading is false after error', async () => {
|
|
166
|
-
const store = createStore({
|
|
167
|
-
user: createAsync(async () => { throw new Error('fail') })
|
|
168
|
-
})
|
|
169
|
-
await store.fetch('user').catch(() => { })
|
|
170
|
-
expect(store.getState().user.loading).toBe(false)
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
it('data is null after error', async () => {
|
|
174
|
-
const store = createStore({
|
|
175
|
-
user: createAsync(async () => { throw new Error('fail') })
|
|
176
|
-
})
|
|
177
|
-
await store.fetch('user').catch(() => { })
|
|
178
|
-
expect(store.getState().user.data).toBeNull()
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
it('non-Error rejection is handled gracefully', async () => {
|
|
182
|
-
const store = createStore({
|
|
183
|
-
user: createAsync(async () => { throw 'string error' })
|
|
184
|
-
})
|
|
185
|
-
await store.fetch('user').catch(() => { })
|
|
186
|
-
expect(store.getState().user.error).toBeTruthy()
|
|
187
|
-
expect(store.getState().user.status).toBe('error')
|
|
188
|
-
})
|
|
189
|
-
|
|
190
|
-
it('fetch does not throw to caller — error is captured in state', async () => {
|
|
191
|
-
const store = createStore({
|
|
192
|
-
user: createAsync(async () => { throw new Error('fail') })
|
|
193
|
-
})
|
|
194
|
-
await expect(store.fetch('user')).resolves.not.toThrow()
|
|
195
|
-
})
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
describe('success → loading → success (refetch)', () => {
|
|
199
|
-
it('refetch sets loading true while in flight', async () => {
|
|
200
|
-
const { promise, resolve } = deferred<{ name: string }>()
|
|
201
|
-
let call = 0
|
|
202
|
-
const store = createStore({
|
|
203
|
-
user: createAsync(async () => {
|
|
204
|
-
call++
|
|
205
|
-
if (call === 1) return { name: 'Alice' }
|
|
206
|
-
return promise // second call returns controlled promise
|
|
207
|
-
})
|
|
208
|
-
})
|
|
209
|
-
await store.fetch('user')
|
|
210
|
-
expect(store.getState().user.data).toEqual({ name: 'Alice' })
|
|
211
|
-
|
|
212
|
-
const refetchPromise = store.refetch('user')
|
|
213
|
-
expect(store.getState().user.loading).toBe(true) // Check BEFORE await
|
|
214
|
-
resolve({ name: 'Bob' })
|
|
215
|
-
await refetchPromise
|
|
216
|
-
expect(store.getState().user.data).toEqual({ name: 'Bob' })
|
|
217
|
-
expect(store.getState().user.loading).toBe(false)
|
|
218
|
-
})
|
|
219
|
-
|
|
220
|
-
it('previous data is preserved while refetching', async () => {
|
|
221
|
-
const { promise, resolve } = deferred<string>()
|
|
222
|
-
let callCount = 0
|
|
223
|
-
const store = createStore({
|
|
224
|
-
msg: createAsync(async () => {
|
|
225
|
-
callCount++
|
|
226
|
-
if (callCount === 1) return 'first'
|
|
227
|
-
return promise
|
|
228
|
-
})
|
|
229
|
-
})
|
|
230
|
-
await store.fetch('msg')
|
|
231
|
-
expect(store.getState().msg.data).toBe('first')
|
|
232
|
-
const refetchP = store.refetch('msg')
|
|
233
|
-
// Data is still 'first' while loading
|
|
234
|
-
expect(store.getState().msg.data).toBe('first')
|
|
235
|
-
expect(store.getState().msg.loading).toBe(true)
|
|
236
|
-
resolve('second')
|
|
237
|
-
await refetchP
|
|
238
|
-
expect(store.getState().msg.data).toBe('second')
|
|
239
|
-
})
|
|
240
|
-
})
|
|
241
|
-
|
|
242
|
-
describe('error → loading → success (retry)', () => {
|
|
243
|
-
it('retry after error clears error field', async () => {
|
|
244
|
-
let callCount = 0
|
|
245
|
-
const store = createStore({
|
|
246
|
-
user: createAsync(async () => {
|
|
247
|
-
callCount++
|
|
248
|
-
if (callCount === 1) throw new Error('fail')
|
|
249
|
-
return { name: 'Alice' }
|
|
250
|
-
})
|
|
251
|
-
})
|
|
252
|
-
await store.fetch('user').catch(() => { })
|
|
253
|
-
expect(store.getState().user.status).toBe('error')
|
|
254
|
-
await store.refetch('user')
|
|
255
|
-
expect(store.getState().user.error).toBeNull()
|
|
256
|
-
expect(store.getState().user.status).toBe('success')
|
|
257
|
-
expect(store.getState().user.data).toEqual({ name: 'Alice' })
|
|
258
|
-
})
|
|
259
|
-
})
|
|
260
|
-
})
|
|
261
|
-
|
|
262
|
-
// ─────────────────────────────────────────────
|
|
263
|
-
// 3. FETCH WITH ARGUMENTS
|
|
264
|
-
// ─────────────────────────────────────────────
|
|
265
|
-
describe('Async State — Fetch Arguments', () => {
|
|
266
|
-
|
|
267
|
-
it('fetch passes single argument to async fn', async () => {
|
|
268
|
-
const fn = vi.fn(async (id: string) => ({ id, name: 'Alice' }))
|
|
269
|
-
const store = createStore({ user: createAsync(fn) })
|
|
270
|
-
await store.fetch('user', 'user-1')
|
|
271
|
-
expect(fn).toHaveBeenCalledWith('user-1')
|
|
272
|
-
})
|
|
273
|
-
|
|
274
|
-
it('fetch passes multiple arguments to async fn', async () => {
|
|
275
|
-
const fn = vi.fn(async (a: string, b: number) => ({ a, b }))
|
|
276
|
-
const store = createStore({ item: createAsync(fn) })
|
|
277
|
-
await store.fetch('item', 'hello', 42)
|
|
278
|
-
expect(fn).toHaveBeenCalledWith('hello', 42)
|
|
279
|
-
})
|
|
280
|
-
|
|
281
|
-
it('fetch with no arguments works', async () => {
|
|
282
|
-
const fn = vi.fn(async () => 'result')
|
|
283
|
-
const store = createStore({ data: createAsync(fn) })
|
|
284
|
-
await store.fetch('data')
|
|
285
|
-
expect(fn).toHaveBeenCalledWith()
|
|
286
|
-
expect(store.getState().data.data).toBe('result')
|
|
287
|
-
})
|
|
288
|
-
|
|
289
|
-
it('refetch uses last arguments automatically', async () => {
|
|
290
|
-
const fn = vi.fn(async (id: string) => ({ id }))
|
|
291
|
-
const store = createStore({ user: createAsync(fn) })
|
|
292
|
-
await store.fetch('user', 'user-42')
|
|
293
|
-
await store.refetch('user')
|
|
294
|
-
expect(fn).toHaveBeenCalledTimes(2)
|
|
295
|
-
expect(fn).toHaveBeenLastCalledWith('user-42')
|
|
296
|
-
})
|
|
297
|
-
|
|
298
|
-
it('refetch on idle store triggers fetch with no args', async () => {
|
|
299
|
-
const fn = vi.fn(async () => 'data')
|
|
300
|
-
const store = createStore({ data: createAsync(fn) })
|
|
301
|
-
await store.refetch('data')
|
|
302
|
-
expect(fn).toHaveBeenCalledTimes(1)
|
|
303
|
-
expect(store.getState().data.status).toBe('success')
|
|
304
|
-
})
|
|
305
|
-
})
|
|
306
|
-
|
|
307
|
-
// ─────────────────────────────────────────────
|
|
308
|
-
// 4. RACE CONDITION PROTECTION
|
|
309
|
-
// ─────────────────────────────────────────────
|
|
310
|
-
describe('Async State — Race Condition Protection', () => {
|
|
311
|
-
|
|
312
|
-
it('two rapid fetches — only last response wins', async () => {
|
|
313
|
-
const { promise: p1, resolve: r1 } = deferred<string>()
|
|
314
|
-
const { promise: p2, resolve: r2 } = deferred<string>()
|
|
315
|
-
let call = 0
|
|
316
|
-
const store = createStore({
|
|
317
|
-
data: createAsync(async () => {
|
|
318
|
-
call++
|
|
319
|
-
return call === 1 ? p1 : p2
|
|
320
|
-
})
|
|
321
|
-
})
|
|
322
|
-
|
|
323
|
-
const f1 = store.fetch('data')
|
|
324
|
-
const f2 = store.fetch('data')
|
|
325
|
-
|
|
326
|
-
// Resolve second first, then first
|
|
327
|
-
r2('second')
|
|
328
|
-
await f2
|
|
329
|
-
r1('first')
|
|
330
|
-
await f1
|
|
331
|
-
|
|
332
|
-
// Only second response should win
|
|
333
|
-
expect(store.getState().data.data).toBe('second')
|
|
334
|
-
})
|
|
335
|
-
|
|
336
|
-
it('three rapid fetches — only last response wins', async () => {
|
|
337
|
-
const deferreds = [deferred<string>(), deferred<string>(), deferred<string>()]
|
|
338
|
-
let call = 0
|
|
339
|
-
const store = createStore({
|
|
340
|
-
data: createAsync(async () => deferreds[call++].promise)
|
|
341
|
-
})
|
|
342
|
-
|
|
343
|
-
const fetches = [store.fetch('data'), store.fetch('data'), store.fetch('data')]
|
|
344
|
-
|
|
345
|
-
// Resolve out of order — last (index 2) should win
|
|
346
|
-
deferreds[2].resolve('third')
|
|
347
|
-
deferreds[0].resolve('first')
|
|
348
|
-
deferreds[1].resolve('second')
|
|
349
|
-
|
|
350
|
-
await Promise.all(fetches)
|
|
351
|
-
expect(store.getState().data.data).toBe('third')
|
|
352
|
-
})
|
|
353
|
-
|
|
354
|
-
it('slow first + fast second — fast second wins', async () => {
|
|
355
|
-
const slow = deferred<string>()
|
|
356
|
-
let call = 0
|
|
357
|
-
const store = createStore({
|
|
358
|
-
data: createAsync(async () => {
|
|
359
|
-
call++
|
|
360
|
-
if (call === 1) return slow.promise
|
|
361
|
-
return 'fast'
|
|
362
|
-
})
|
|
363
|
-
})
|
|
364
|
-
|
|
365
|
-
const f1 = store.fetch('data') // slow
|
|
366
|
-
const f2 = store.fetch('data') // fast
|
|
367
|
-
|
|
368
|
-
await f2 // fast resolves first
|
|
369
|
-
expect(store.getState().data.data).toBe('fast')
|
|
370
|
-
|
|
371
|
-
slow.resolve('slow')
|
|
372
|
-
await f1 // slow resolves — should be ignored
|
|
373
|
-
expect(store.getState().data.data).toBe('fast')
|
|
374
|
-
})
|
|
375
|
-
|
|
376
|
-
it('stale slow response does not overwrite newer success', async () => {
|
|
377
|
-
const slow = deferred<string>()
|
|
378
|
-
let call = 0
|
|
379
|
-
const store = createStore({
|
|
380
|
-
data: createAsync(async () => {
|
|
381
|
-
call++
|
|
382
|
-
if (call === 1) return slow.promise
|
|
383
|
-
return 'new'
|
|
384
|
-
})
|
|
385
|
-
})
|
|
386
|
-
|
|
387
|
-
store.fetch('data') // slow request
|
|
388
|
-
await store.fetch('data') // fast request — wins
|
|
389
|
-
expect(store.getState().data.data).toBe('new')
|
|
390
|
-
expect(store.getState().data.status).toBe('success')
|
|
391
|
-
|
|
392
|
-
slow.resolve('old')
|
|
393
|
-
await wait(10)
|
|
394
|
-
expect(store.getState().data.data).toBe('new') // not overwritten
|
|
395
|
-
})
|
|
396
|
-
|
|
397
|
-
it('stale error does not overwrite newer success', async () => {
|
|
398
|
-
const slow = deferred<string>()
|
|
399
|
-
let call = 0
|
|
400
|
-
const store = createStore({
|
|
401
|
-
data: createAsync(async () => {
|
|
402
|
-
call++
|
|
403
|
-
if (call === 1) return slow.promise
|
|
404
|
-
return 'new'
|
|
405
|
-
})
|
|
406
|
-
})
|
|
407
|
-
|
|
408
|
-
store.fetch('data') // will be stale
|
|
409
|
-
await store.fetch('data') // new success
|
|
410
|
-
|
|
411
|
-
slow.reject(new Error('stale error'))
|
|
412
|
-
await wait(10)
|
|
413
|
-
|
|
414
|
-
expect(store.getState().data.status).toBe('success')
|
|
415
|
-
expect(store.getState().data.data).toBe('new')
|
|
416
|
-
expect(store.getState().data.error).toBeNull()
|
|
417
|
-
})
|
|
418
|
-
|
|
419
|
-
it('10 rapid fetches — only last wins', async () => {
|
|
420
|
-
const deferreds = Array.from({ length: 10 }, () => deferred<number>())
|
|
421
|
-
let call = 0
|
|
422
|
-
const store = createStore({
|
|
423
|
-
data: createAsync(async () => deferreds[call++].promise)
|
|
424
|
-
})
|
|
425
|
-
|
|
426
|
-
const fetches = Array.from({ length: 10 }, () => store.fetch('data'))
|
|
427
|
-
|
|
428
|
-
// Resolve all — last one wins
|
|
429
|
-
deferreds.forEach((d, i) => d.resolve(i))
|
|
430
|
-
await Promise.all(fetches)
|
|
431
|
-
|
|
432
|
-
expect(store.getState().data.data).toBe(9)
|
|
433
|
-
})
|
|
434
|
-
})
|
|
435
|
-
|
|
436
|
-
// ─────────────────────────────────────────────
|
|
437
|
-
// 5. CACHE & TTL
|
|
438
|
-
// ─────────────────────────────────────────────
|
|
439
|
-
describe('Async State — Cache & TTL', () => {
|
|
440
|
-
|
|
441
|
-
beforeEach(() => { vi.useFakeTimers({ now: Date.now(), toFake: ['Date', 'setTimeout', 'clearTimeout'] }) })
|
|
442
|
-
afterEach(() => { vi.useRealTimers() })
|
|
443
|
-
|
|
444
|
-
it('ttl=0 (default) — every fetch calls the fn', async () => {
|
|
445
|
-
const fn = vi.fn(async () => 'data')
|
|
446
|
-
const store = createStore({ data: createAsync(fn) })
|
|
447
|
-
await store.fetch('data')
|
|
448
|
-
await store.fetch('data')
|
|
449
|
-
expect(fn).toHaveBeenCalledTimes(2)
|
|
450
|
-
})
|
|
451
|
-
|
|
452
|
-
it('within TTL — second fetch returns cached data without calling fn', async () => {
|
|
453
|
-
const fn = vi.fn(async () => 'data')
|
|
454
|
-
const store = createStore({ data: createAsync(fn, { ttl: 60_000 }) })
|
|
455
|
-
await store.fetch('data')
|
|
456
|
-
await store.fetch('data')
|
|
457
|
-
expect(fn).toHaveBeenCalledTimes(1)
|
|
458
|
-
expect(store.getState().data.data).toBe('data')
|
|
459
|
-
})
|
|
460
|
-
|
|
461
|
-
it('within TTL — cached data is returned immediately', async () => {
|
|
462
|
-
const fn = vi.fn(async () => 'cached')
|
|
463
|
-
const store = createStore({ data: createAsync(fn, { ttl: 60_000 }) })
|
|
464
|
-
await store.fetch('data')
|
|
465
|
-
await store.fetch('data')
|
|
466
|
-
expect(store.getState().data.status).toBe('success')
|
|
467
|
-
expect(store.getState().data.data).toBe('cached')
|
|
468
|
-
})
|
|
469
|
-
|
|
470
|
-
it('after TTL expires — fetch calls fn again', async () => {
|
|
471
|
-
const fn = vi.fn(async () => 'data')
|
|
472
|
-
const store = createStore({ data: createAsync(fn, { ttl: 1_000 }) })
|
|
473
|
-
await store.fetch('data')
|
|
474
|
-
vi.advanceTimersByTime(1_001)
|
|
475
|
-
await store.fetch('data')
|
|
476
|
-
expect(fn).toHaveBeenCalledTimes(2)
|
|
477
|
-
})
|
|
478
|
-
|
|
479
|
-
it('exactly at TTL boundary — fetch calls fn again', async () => {
|
|
480
|
-
const fn = vi.fn(async () => 'data')
|
|
481
|
-
const store = createStore({ data: createAsync(fn, { ttl: 1_000 }) })
|
|
482
|
-
await store.fetch('data')
|
|
483
|
-
vi.advanceTimersByTime(1_000)
|
|
484
|
-
await store.fetch('data')
|
|
485
|
-
expect(fn).toHaveBeenCalledTimes(2)
|
|
486
|
-
})
|
|
487
|
-
|
|
488
|
-
it('just before TTL — cache hit', async () => {
|
|
489
|
-
const fn = vi.fn(async () => 'data')
|
|
490
|
-
const store = createStore({ data: createAsync(fn, { ttl: 1_000 }) })
|
|
491
|
-
await store.fetch('data')
|
|
492
|
-
vi.advanceTimersByTime(999)
|
|
493
|
-
await store.fetch('data')
|
|
494
|
-
expect(fn).toHaveBeenCalledTimes(1)
|
|
495
|
-
})
|
|
496
|
-
|
|
497
|
-
it('invalidate() clears cache — next fetch calls fn regardless of TTL', async () => {
|
|
498
|
-
const fn = vi.fn(async () => 'data')
|
|
499
|
-
const store = createStore({ data: createAsync(fn, { ttl: 60_000 }) })
|
|
500
|
-
await store.fetch('data')
|
|
501
|
-
store.invalidate('data')
|
|
502
|
-
await store.fetch('data')
|
|
503
|
-
expect(fn).toHaveBeenCalledTimes(2)
|
|
504
|
-
})
|
|
505
|
-
|
|
506
|
-
it('invalidate() does not reset state to idle', async () => {
|
|
507
|
-
const fn = vi.fn(async () => 'data')
|
|
508
|
-
const store = createStore({ data: createAsync(fn, { ttl: 60_000 }) })
|
|
509
|
-
await store.fetch('data')
|
|
510
|
-
store.invalidate('data')
|
|
511
|
-
// State should still show previous data until next fetch
|
|
512
|
-
expect(store.getState().data.data).toBe('data')
|
|
513
|
-
expect(store.getState().data.status).toBe('success')
|
|
514
|
-
})
|
|
515
|
-
|
|
516
|
-
it('invalidateAll() clears cache for all async keys', async () => {
|
|
517
|
-
const fn1 = vi.fn(async () => 'a')
|
|
518
|
-
const fn2 = vi.fn(async () => 'b')
|
|
519
|
-
const store = createStore({
|
|
520
|
-
a: createAsync(fn1, { ttl: 60_000 }),
|
|
521
|
-
b: createAsync(fn2, { ttl: 60_000 }),
|
|
522
|
-
})
|
|
523
|
-
await store.fetch('a')
|
|
524
|
-
await store.fetch('b')
|
|
525
|
-
store.invalidateAll()
|
|
526
|
-
await store.fetch('a')
|
|
527
|
-
await store.fetch('b')
|
|
528
|
-
expect(fn1).toHaveBeenCalledTimes(2)
|
|
529
|
-
expect(fn2).toHaveBeenCalledTimes(2)
|
|
530
|
-
})
|
|
531
|
-
|
|
532
|
-
it('invalidate() only affects specified key', async () => {
|
|
533
|
-
const fn1 = vi.fn(async () => 'a')
|
|
534
|
-
const fn2 = vi.fn(async () => 'b')
|
|
535
|
-
const store = createStore({
|
|
536
|
-
a: createAsync(fn1, { ttl: 60_000 }),
|
|
537
|
-
b: createAsync(fn2, { ttl: 60_000 }),
|
|
538
|
-
})
|
|
539
|
-
await store.fetch('a')
|
|
540
|
-
await store.fetch('b')
|
|
541
|
-
store.invalidate('a')
|
|
542
|
-
await store.fetch('a')
|
|
543
|
-
await store.fetch('b')
|
|
544
|
-
expect(fn1).toHaveBeenCalledTimes(2) // refetched
|
|
545
|
-
expect(fn2).toHaveBeenCalledTimes(1) // still cached
|
|
546
|
-
})
|
|
547
|
-
|
|
548
|
-
it('different args produce independent cache entries', async () => {
|
|
549
|
-
const fn = vi.fn(async (id: string) => ({ id }))
|
|
550
|
-
const store = createStore({ user: createAsync(fn, { ttl: 60_000 }) })
|
|
551
|
-
await store.fetch('user', 'user-1')
|
|
552
|
-
await store.fetch('user', 'user-2')
|
|
553
|
-
await store.fetch('user', 'user-1') // cache hit for user-1
|
|
554
|
-
expect(fn).toHaveBeenCalledTimes(2) // user-1 and user-2 only
|
|
555
|
-
})
|
|
556
|
-
})
|
|
557
|
-
|
|
558
|
-
// ─────────────────────────────────────────────
|
|
559
|
-
// 6. STALE-WHILE-REVALIDATE
|
|
560
|
-
// ─────────────────────────────────────────────
|
|
561
|
-
describe('Async State — Stale-While-Revalidate', () => {
|
|
562
|
-
|
|
563
|
-
beforeEach(() => { vi.useFakeTimers({ now: Date.now(), toFake: ['Date', 'setTimeout', 'clearTimeout'] }) })
|
|
564
|
-
afterEach(() => { vi.useRealTimers() })
|
|
565
|
-
|
|
566
|
-
it('returns stale data immediately when cache is expired', async () => {
|
|
567
|
-
let call = 0
|
|
568
|
-
const { promise, resolve } = deferred<string>()
|
|
569
|
-
const store = createStore({
|
|
570
|
-
data: createAsync(async () => {
|
|
571
|
-
call++
|
|
572
|
-
if (call === 1) return 'stale'
|
|
573
|
-
return promise
|
|
574
|
-
}, { ttl: 1_000, staleWhileRevalidate: true })
|
|
575
|
-
})
|
|
576
|
-
|
|
577
|
-
await store.fetch('data')
|
|
578
|
-
vi.advanceTimersByTime(1_001)
|
|
579
|
-
|
|
580
|
-
const fetchP = store.fetch('data')
|
|
581
|
-
// Stale data returned immediately
|
|
582
|
-
expect(store.getState().data.data).toBe('stale')
|
|
583
|
-
expect(store.getState().data.status).toBe('success') // not loading
|
|
584
|
-
|
|
585
|
-
resolve('fresh')
|
|
586
|
-
await fetchP
|
|
587
|
-
expect(store.getState().data.data).toBe('fresh')
|
|
588
|
-
})
|
|
589
|
-
|
|
590
|
-
it('status stays success during background revalidation', async () => {
|
|
591
|
-
const { promise, resolve } = deferred<string>()
|
|
592
|
-
let call = 0
|
|
593
|
-
const store = createStore({
|
|
594
|
-
data: createAsync(async () => {
|
|
595
|
-
call++
|
|
596
|
-
if (call === 1) return 'stale'
|
|
597
|
-
return promise
|
|
598
|
-
}, { ttl: 500, staleWhileRevalidate: true })
|
|
599
|
-
})
|
|
600
|
-
|
|
601
|
-
await store.fetch('data')
|
|
602
|
-
vi.advanceTimersByTime(501)
|
|
603
|
-
store.fetch('data')
|
|
604
|
-
|
|
605
|
-
// Status must NOT be loading during SWR background fetch
|
|
606
|
-
expect(store.getState().data.status).toBe('success')
|
|
607
|
-
expect(store.getState().data.loading).toBe(false)
|
|
608
|
-
|
|
609
|
-
resolve('fresh')
|
|
610
|
-
})
|
|
611
|
-
|
|
612
|
-
it('background revalidation updates data when resolved', async () => {
|
|
613
|
-
const { promise, resolve } = deferred<string>()
|
|
614
|
-
let call = 0
|
|
615
|
-
const store = createStore({
|
|
616
|
-
data: createAsync(async () => {
|
|
617
|
-
call++
|
|
618
|
-
if (call === 1) return 'stale'
|
|
619
|
-
return promise
|
|
620
|
-
}, { ttl: 500, staleWhileRevalidate: true })
|
|
621
|
-
})
|
|
622
|
-
|
|
623
|
-
await store.fetch('data')
|
|
624
|
-
vi.advanceTimersByTime(501)
|
|
625
|
-
const fetchP = store.fetch('data')
|
|
626
|
-
|
|
627
|
-
resolve('fresh')
|
|
628
|
-
await fetchP
|
|
629
|
-
expect(store.getState().data.data).toBe('fresh')
|
|
630
|
-
})
|
|
631
|
-
|
|
632
|
-
it('without SWR — expired cache shows loading state', async () => {
|
|
633
|
-
const { promise, resolve } = deferred<string>()
|
|
634
|
-
let call = 0
|
|
635
|
-
const store = createStore({
|
|
636
|
-
data: createAsync(async () => {
|
|
637
|
-
call++
|
|
638
|
-
if (call === 1) return 'first'
|
|
639
|
-
return promise
|
|
640
|
-
}, { ttl: 500, staleWhileRevalidate: false })
|
|
641
|
-
})
|
|
642
|
-
|
|
643
|
-
await store.fetch('data')
|
|
644
|
-
vi.advanceTimersByTime(501)
|
|
645
|
-
store.fetch('data')
|
|
646
|
-
|
|
647
|
-
// Without SWR, status should be loading
|
|
648
|
-
expect(store.getState().data.status).toBe('loading')
|
|
649
|
-
expect(store.getState().data.loading).toBe(true)
|
|
650
|
-
|
|
651
|
-
resolve('second')
|
|
652
|
-
})
|
|
653
|
-
|
|
654
|
-
it('SWR background error does not wipe stale data', async () => {
|
|
655
|
-
const { promise, reject } = deferred<string>()
|
|
656
|
-
let call = 0
|
|
657
|
-
const store = createStore({
|
|
658
|
-
data: createAsync(async () => {
|
|
659
|
-
call++
|
|
660
|
-
if (call === 1) return 'stale'
|
|
661
|
-
return promise
|
|
662
|
-
}, { ttl: 500, staleWhileRevalidate: true })
|
|
663
|
-
})
|
|
664
|
-
|
|
665
|
-
await store.fetch('data')
|
|
666
|
-
vi.advanceTimersByTime(501)
|
|
667
|
-
const fetchP = store.fetch('data')
|
|
668
|
-
|
|
669
|
-
reject(new Error('background fail'))
|
|
670
|
-
await fetchP
|
|
671
|
-
|
|
672
|
-
// Stale data preserved, error surfaced
|
|
673
|
-
expect(store.getState().data.data).toBe('stale')
|
|
674
|
-
expect(store.getState().data.error).toBe('background fail')
|
|
675
|
-
})
|
|
676
|
-
})
|
|
677
|
-
|
|
678
|
-
// ─────────────────────────────────────────────
|
|
679
|
-
// 7. OPTIMISTIC UPDATES
|
|
680
|
-
// ─────────────────────────────────────────────
|
|
681
|
-
describe('Async State — Optimistic Updates', () => {
|
|
682
|
-
|
|
683
|
-
it('optimistic state is applied immediately before fetch resolves', async () => {
|
|
684
|
-
const { promise, resolve } = deferred<{ name: string }>()
|
|
685
|
-
const store = createStore({
|
|
686
|
-
user: createAsync(async () => promise)
|
|
687
|
-
})
|
|
688
|
-
|
|
689
|
-
store.fetch('user', undefined, {
|
|
690
|
-
optimistic: { data: { name: 'Optimistic Alice' }, status: 'success' }
|
|
691
|
-
})
|
|
692
|
-
|
|
693
|
-
expect(store.getState().user.data).toEqual({ name: 'Optimistic Alice' })
|
|
694
|
-
expect(store.getState().user.status).toBe('success')
|
|
695
|
-
|
|
696
|
-
resolve({ name: 'Real Alice' })
|
|
697
|
-
await wait(10)
|
|
698
|
-
})
|
|
699
|
-
|
|
700
|
-
it('on success — final data replaces optimistic data', async () => {
|
|
701
|
-
const store = createStore({
|
|
702
|
-
user: createAsync(async () => ({ name: 'Real Alice' }))
|
|
703
|
-
})
|
|
704
|
-
|
|
705
|
-
await store.fetch('user', undefined, {
|
|
706
|
-
optimistic: { data: { name: 'Optimistic Alice' }, status: 'success' }
|
|
707
|
-
})
|
|
708
|
-
|
|
709
|
-
expect(store.getState().user.data).toEqual({ name: 'Real Alice' })
|
|
710
|
-
})
|
|
711
|
-
|
|
712
|
-
it('on failure — state rolls back to pre-optimistic values', async () => {
|
|
713
|
-
const store = createStore({
|
|
714
|
-
user: createAsync(async () => ({ name: 'Original' }))
|
|
715
|
-
})
|
|
716
|
-
|
|
717
|
-
// First fetch to set initial data
|
|
718
|
-
await store.fetch('user')
|
|
719
|
-
expect(store.getState().user.data).toEqual({ name: 'Original' })
|
|
720
|
-
|
|
721
|
-
// Optimistic update that fails
|
|
722
|
-
await store.fetch('user', undefined, {
|
|
723
|
-
optimistic: { data: { name: 'Optimistic' }, status: 'success' },
|
|
724
|
-
// Override fn to simulate failure
|
|
725
|
-
})
|
|
726
|
-
// After rollback, original data should be restored
|
|
727
|
-
// (depends on implementation — adjust based on actual rollback behavior)
|
|
728
|
-
})
|
|
729
|
-
|
|
730
|
-
it('optimistic loading is false', async () => {
|
|
731
|
-
const { promise, resolve } = deferred<{ name: string }>()
|
|
732
|
-
const store = createStore({
|
|
733
|
-
user: createAsync(async () => promise)
|
|
734
|
-
})
|
|
735
|
-
|
|
736
|
-
store.fetch('user', undefined, {
|
|
737
|
-
optimistic: { data: { name: 'Alice' }, status: 'success' }
|
|
738
|
-
})
|
|
739
|
-
|
|
740
|
-
expect(store.getState().user.loading).toBe(false)
|
|
741
|
-
resolve({ name: 'Alice' })
|
|
742
|
-
})
|
|
743
|
-
})
|
|
744
|
-
|
|
745
|
-
// ─────────────────────────────────────────────
|
|
746
|
-
// 8. SUBSCRIBERS & NOTIFICATIONS
|
|
747
|
-
// ─────────────────────────────────────────────
|
|
748
|
-
describe('Async State — Subscribers', () => {
|
|
749
|
-
|
|
750
|
-
it('subscribers notified when loading starts', async () => {
|
|
751
|
-
const { promise, resolve } = deferred<string>()
|
|
752
|
-
const store = createStore({ data: createAsync(async () => promise) })
|
|
753
|
-
const listener = vi.fn()
|
|
754
|
-
store.subscribe(listener)
|
|
755
|
-
|
|
756
|
-
store.fetch('data')
|
|
757
|
-
expect(listener).toHaveBeenCalled()
|
|
758
|
-
const calls = listener.mock.calls.length
|
|
759
|
-
expect((listener.mock.calls[calls - 1][0] as { data: { loading: boolean } }).data.loading).toBe(true)
|
|
760
|
-
|
|
761
|
-
resolve('done')
|
|
762
|
-
await wait(10)
|
|
763
|
-
})
|
|
764
|
-
|
|
765
|
-
it('subscribers notified when data resolves', async () => {
|
|
766
|
-
const store = createStore({ data: createAsync(async () => 'result') })
|
|
767
|
-
const listener = vi.fn()
|
|
768
|
-
store.subscribe(listener)
|
|
769
|
-
await store.fetch('data')
|
|
770
|
-
const lastState = listener.mock.calls[listener.mock.calls.length - 1][0] as { data: { data: string } }
|
|
771
|
-
expect(lastState.data.data).toBe('result')
|
|
772
|
-
})
|
|
773
|
-
|
|
774
|
-
it('subscribers notified when error occurs', async () => {
|
|
775
|
-
const store = createStore({
|
|
776
|
-
data: createAsync(async () => { throw new Error('fail') })
|
|
777
|
-
})
|
|
778
|
-
const listener = vi.fn()
|
|
779
|
-
store.subscribe(listener)
|
|
780
|
-
await store.fetch('data').catch(() => { })
|
|
781
|
-
const lastState = listener.mock.calls[listener.mock.calls.length - 1][0] as { data: { error: string } }
|
|
782
|
-
expect(lastState.data.error).toBe('fail')
|
|
783
|
-
})
|
|
784
|
-
|
|
785
|
-
it('subscriber receives exactly 2 notifications for successful fetch (loading + success)', async () => {
|
|
786
|
-
const store = createStore({ data: createAsync(async () => 'done') })
|
|
787
|
-
const listener = vi.fn()
|
|
788
|
-
store.subscribe(listener)
|
|
789
|
-
await store.fetch('data')
|
|
790
|
-
// loading transition + success transition = 2
|
|
791
|
-
expect(listener).toHaveBeenCalledTimes(2)
|
|
792
|
-
})
|
|
793
|
-
|
|
794
|
-
it('unsubscribed listener does not receive async notifications', async () => {
|
|
795
|
-
const store = createStore({ data: createAsync(async () => 'done') })
|
|
796
|
-
const listener = vi.fn()
|
|
797
|
-
const unsub = store.subscribe(listener)
|
|
798
|
-
unsub()
|
|
799
|
-
await store.fetch('data')
|
|
800
|
-
expect(listener).not.toHaveBeenCalled()
|
|
801
|
-
})
|
|
802
|
-
|
|
803
|
-
it('multiple subscribers all receive async state updates', async () => {
|
|
804
|
-
const store = createStore({ data: createAsync(async () => 'done') })
|
|
805
|
-
const l1 = vi.fn(), l2 = vi.fn(), l3 = vi.fn()
|
|
806
|
-
store.subscribe(l1)
|
|
807
|
-
store.subscribe(l2)
|
|
808
|
-
store.subscribe(l3)
|
|
809
|
-
await store.fetch('data')
|
|
810
|
-
expect(l1).toHaveBeenCalled()
|
|
811
|
-
expect(l2).toHaveBeenCalled()
|
|
812
|
-
expect(l3).toHaveBeenCalled()
|
|
813
|
-
})
|
|
814
|
-
|
|
815
|
-
it('async state update does not affect sync state subscribers unnecessarily', async () => {
|
|
816
|
-
const store = createStore({
|
|
817
|
-
count: 0,
|
|
818
|
-
data: createAsync(async () => 'done')
|
|
819
|
-
})
|
|
820
|
-
const countSnapshots: number[] = []
|
|
821
|
-
store.subscribe(s => countSnapshots.push((s as { count: number }).count))
|
|
822
|
-
await store.fetch('data')
|
|
823
|
-
// count should never change
|
|
824
|
-
expect(countSnapshots.every(c => c === 0)).toBe(true)
|
|
825
|
-
})
|
|
826
|
-
})
|
|
827
|
-
|
|
828
|
-
// ─────────────────────────────────────────────
|
|
829
|
-
// 9. GETASYNCSTATE
|
|
830
|
-
// ─────────────────────────────────────────────
|
|
831
|
-
describe('Async State — getAsyncState()', () => {
|
|
832
|
-
|
|
833
|
-
it('getAsyncState returns current async state synchronously', async () => {
|
|
834
|
-
const store = createStore({ data: createAsync(async () => 'result') })
|
|
835
|
-
await store.fetch('data')
|
|
836
|
-
const state = store.getAsyncState('data')
|
|
837
|
-
expect(state.data).toBe('result')
|
|
838
|
-
expect(state.status).toBe('success')
|
|
839
|
-
})
|
|
840
|
-
|
|
841
|
-
it('getAsyncState on idle key returns initial shape', () => {
|
|
842
|
-
const store = createStore({ data: createAsync(async () => 'result') })
|
|
843
|
-
const state = store.getAsyncState('data')
|
|
844
|
-
expect(state.status).toBe('idle')
|
|
845
|
-
expect(state.data).toBeNull()
|
|
846
|
-
})
|
|
847
|
-
|
|
848
|
-
it('getAsyncState is consistent with getState()', async () => {
|
|
849
|
-
const store = createStore({ data: createAsync(async () => 'result') })
|
|
850
|
-
await store.fetch('data')
|
|
851
|
-
const fromGetState = store.getState().data
|
|
852
|
-
const fromGetAsyncState = store.getAsyncState('data')
|
|
853
|
-
expect(fromGetState).toEqual(fromGetAsyncState)
|
|
854
|
-
})
|
|
855
|
-
})
|
|
856
|
-
|
|
857
|
-
// ─────────────────────────────────────────────
|
|
858
|
-
// 10. REFETCH CONVENIENCE METHOD
|
|
859
|
-
// ─────────────────────────────────────────────
|
|
860
|
-
describe('Async State — refetch() convenience method', () => {
|
|
861
|
-
|
|
862
|
-
it('value.refetch() re-runs the fetch', async () => {
|
|
863
|
-
const fn = vi.fn(async () => 'data')
|
|
864
|
-
const store = createStore({ data: createAsync(fn) })
|
|
865
|
-
await store.fetch('data')
|
|
866
|
-
await store.getState().data.refetch()
|
|
867
|
-
expect(fn).toHaveBeenCalledTimes(2)
|
|
868
|
-
})
|
|
869
|
-
|
|
870
|
-
it('value.refetch() updates state correctly', async () => {
|
|
871
|
-
let call = 0
|
|
872
|
-
const store = createStore({
|
|
873
|
-
data: createAsync(async () => {
|
|
874
|
-
call++
|
|
875
|
-
return `call-${call}`
|
|
876
|
-
})
|
|
877
|
-
})
|
|
878
|
-
await store.fetch('data')
|
|
879
|
-
expect(store.getState().data.data).toBe('call-1')
|
|
880
|
-
await store.getState().data.refetch()
|
|
881
|
-
expect(store.getState().data.data).toBe('call-2')
|
|
882
|
-
})
|
|
883
|
-
})
|
|
884
|
-
|
|
885
|
-
// ─────────────────────────────────────────────
|
|
886
|
-
// 11. MULTIPLE ASYNC KEYS — ISOLATION
|
|
887
|
-
// ─────────────────────────────────────────────
|
|
888
|
-
describe('Async State — Multiple Keys Isolation', () => {
|
|
889
|
-
|
|
890
|
-
it('fetching key A does not affect key B state', async () => {
|
|
891
|
-
const store = createStore({
|
|
892
|
-
user: createAsync(async () => ({ name: 'Alice' })),
|
|
893
|
-
posts: createAsync(async () => [{ id: 1 }]),
|
|
894
|
-
})
|
|
895
|
-
await store.fetch('user')
|
|
896
|
-
expect(store.getState().posts.status).toBe('idle')
|
|
897
|
-
expect(store.getState().posts.data).toBeNull()
|
|
898
|
-
})
|
|
899
|
-
|
|
900
|
-
it('error in key A does not affect key B', async () => {
|
|
901
|
-
const store = createStore({
|
|
902
|
-
a: createAsync(async () => { throw new Error('fail') }),
|
|
903
|
-
b: createAsync(async () => 'ok'),
|
|
904
|
-
})
|
|
905
|
-
await store.fetch('a').catch(() => { })
|
|
906
|
-
await store.fetch('b')
|
|
907
|
-
expect(store.getState().a.status).toBe('error')
|
|
908
|
-
expect(store.getState().b.status).toBe('success')
|
|
909
|
-
})
|
|
910
|
-
|
|
911
|
-
it('race condition in key A does not affect key B', async () => {
|
|
912
|
-
const slow = deferred<string>()
|
|
913
|
-
let callA = 0
|
|
914
|
-
const store = createStore({
|
|
915
|
-
a: createAsync(async () => {
|
|
916
|
-
callA++
|
|
917
|
-
if (callA === 1) return slow.promise
|
|
918
|
-
return 'fast-a'
|
|
919
|
-
}),
|
|
920
|
-
b: createAsync(async () => 'b-data'),
|
|
921
|
-
})
|
|
922
|
-
|
|
923
|
-
store.fetch('a') // slow
|
|
924
|
-
store.fetch('a') // fast
|
|
925
|
-
await store.fetch('b')
|
|
926
|
-
|
|
927
|
-
expect(store.getState().b.data).toBe('b-data')
|
|
928
|
-
slow.resolve('slow-a')
|
|
929
|
-
})
|
|
930
|
-
|
|
931
|
-
it('invalidateAll clears all keys but does not reset state', async () => {
|
|
932
|
-
const store = createStore({
|
|
933
|
-
a: createAsync(async () => 'a', { ttl: 60_000 }),
|
|
934
|
-
b: createAsync(async () => 'b', { ttl: 60_000 }),
|
|
935
|
-
})
|
|
936
|
-
await store.fetch('a')
|
|
937
|
-
await store.fetch('b')
|
|
938
|
-
store.invalidateAll()
|
|
939
|
-
// State still shows previous data
|
|
940
|
-
expect(store.getState().a.data).toBe('a')
|
|
941
|
-
expect(store.getState().b.data).toBe('b')
|
|
942
|
-
})
|
|
943
|
-
})
|
|
944
|
-
|
|
945
|
-
// ─────────────────────────────────────────────
|
|
946
|
-
// 12. ASYNC + SYNC STATE COEXISTENCE
|
|
947
|
-
// ─────────────────────────────────────────────
|
|
948
|
-
describe('Async State — Coexistence with Sync State', () => {
|
|
949
|
-
|
|
950
|
-
it('setState on sync key does not affect async key', async () => {
|
|
951
|
-
const store = createStore({
|
|
952
|
-
count: 0,
|
|
953
|
-
data: createAsync(async () => 'result'),
|
|
954
|
-
})
|
|
955
|
-
await store.fetch('data')
|
|
956
|
-
store.setState({ count: 99 })
|
|
957
|
-
expect(store.getState().data.status).toBe('success')
|
|
958
|
-
expect(store.getState().data.data).toBe('result')
|
|
959
|
-
})
|
|
960
|
-
|
|
961
|
-
it('async fetch does not affect sync state', async () => {
|
|
962
|
-
const store = createStore({
|
|
963
|
-
count: 42,
|
|
964
|
-
data: createAsync(async () => 'result'),
|
|
965
|
-
})
|
|
966
|
-
await store.fetch('data')
|
|
967
|
-
expect(store.getState().count).toBe(42)
|
|
968
|
-
})
|
|
969
|
-
|
|
970
|
-
it('actions and async keys work together', async () => {
|
|
971
|
-
const store = createStore({
|
|
972
|
-
theme: 'light',
|
|
973
|
-
user: createAsync(async () => ({ name: 'Alice' })),
|
|
974
|
-
actions: {
|
|
975
|
-
setTheme(t: string) { store.setState({ theme: t }) }
|
|
976
|
-
}
|
|
977
|
-
})
|
|
978
|
-
store.setTheme('dark')
|
|
979
|
-
await store.fetch('user')
|
|
980
|
-
expect(store.getState().theme).toBe('dark')
|
|
981
|
-
expect(store.getState().user.data).toEqual({ name: 'Alice' })
|
|
982
|
-
})
|
|
983
|
-
|
|
984
|
-
it('immer mutations and async keys work together', async () => {
|
|
985
|
-
const store = createStore({
|
|
986
|
-
items: [] as string[],
|
|
987
|
-
data: createAsync(async () => 'async-result'),
|
|
988
|
-
}, { immer: true })
|
|
989
|
-
store.setState(draft => { draft.items.push('hello') })
|
|
990
|
-
await store.fetch('data')
|
|
991
|
-
expect(store.getState().items).toEqual(['hello'])
|
|
992
|
-
expect(store.getState().data.data).toBe('async-result')
|
|
993
|
-
})
|
|
994
|
-
|
|
995
|
-
it('batch updates work with async state notifications', async () => {
|
|
996
|
-
const store = createStore({
|
|
997
|
-
a: 0,
|
|
998
|
-
b: 0,
|
|
999
|
-
data: createAsync(async () => 'result'),
|
|
1000
|
-
})
|
|
1001
|
-
const listener = vi.fn()
|
|
1002
|
-
store.subscribe(listener)
|
|
1003
|
-
store.batch(() => {
|
|
1004
|
-
store.setState({ a: 1 })
|
|
1005
|
-
store.setState({ b: 2 })
|
|
1006
|
-
})
|
|
1007
|
-
expect(listener).toHaveBeenCalledTimes(1)
|
|
1008
|
-
await store.fetch('data')
|
|
1009
|
-
// async transitions fire their own notifications outside batch
|
|
1010
|
-
expect(store.getState().a).toBe(1)
|
|
1011
|
-
expect(store.getState().b).toBe(2)
|
|
1012
|
-
})
|
|
1013
|
-
})
|
|
1014
|
-
|
|
1015
|
-
// ─────────────────────────────────────────────
|
|
1016
|
-
// 13. EDGE CASES
|
|
1017
|
-
// ─────────────────────────────────────────────
|
|
1018
|
-
describe('Async State — Edge Cases', () => {
|
|
1019
|
-
|
|
1020
|
-
it('async fn returning null is valid', async () => {
|
|
1021
|
-
const store = createStore({ data: createAsync(async () => null) })
|
|
1022
|
-
await store.fetch('data')
|
|
1023
|
-
expect(store.getState().data.data).toBeNull()
|
|
1024
|
-
expect(store.getState().data.status).toBe('success')
|
|
1025
|
-
})
|
|
1026
|
-
|
|
1027
|
-
it('async fn returning undefined is valid', async () => {
|
|
1028
|
-
const store = createStore({ data: createAsync(async () => undefined) })
|
|
1029
|
-
await store.fetch('data')
|
|
1030
|
-
expect(store.getState().data.status).toBe('success')
|
|
1031
|
-
})
|
|
1032
|
-
|
|
1033
|
-
it('async fn returning 0 is valid', async () => {
|
|
1034
|
-
const store = createStore({ data: createAsync(async () => 0) })
|
|
1035
|
-
await store.fetch('data')
|
|
1036
|
-
expect(store.getState().data.data).toBe(0)
|
|
1037
|
-
expect(store.getState().data.status).toBe('success')
|
|
1038
|
-
})
|
|
1039
|
-
|
|
1040
|
-
it('async fn returning false is valid', async () => {
|
|
1041
|
-
const store = createStore({ data: createAsync(async () => false) })
|
|
1042
|
-
await store.fetch('data')
|
|
1043
|
-
expect(store.getState().data.data).toBe(false)
|
|
1044
|
-
expect(store.getState().data.status).toBe('success')
|
|
1045
|
-
})
|
|
1046
|
-
|
|
1047
|
-
it('async fn returning empty string is valid', async () => {
|
|
1048
|
-
const store = createStore({ data: createAsync(async () => '') })
|
|
1049
|
-
await store.fetch('data')
|
|
1050
|
-
expect(store.getState().data.data).toBe('')
|
|
1051
|
-
expect(store.getState().data.status).toBe('success')
|
|
1052
|
-
})
|
|
1053
|
-
|
|
1054
|
-
it('async fn returning empty array is valid', async () => {
|
|
1055
|
-
const store = createStore({ data: createAsync(async () => []) })
|
|
1056
|
-
await store.fetch('data')
|
|
1057
|
-
expect(store.getState().data.data).toEqual([])
|
|
1058
|
-
expect(store.getState().data.status).toBe('success')
|
|
1059
|
-
})
|
|
1060
|
-
|
|
1061
|
-
it('fetching a non-existent key throws or handles gracefully', async () => {
|
|
1062
|
-
const store = createStore({ data: createAsync(async () => 'ok') })
|
|
1063
|
-
await expect(store.fetch('nonExistent' as never)).rejects.toThrow(
|
|
1064
|
-
'Storve: no async key "nonExistent" found in store'
|
|
1065
|
-
)
|
|
1066
|
-
})
|
|
1067
|
-
|
|
1068
|
-
it('store with 10 async keys all independent', async () => {
|
|
1069
|
-
const fns = Array.from({ length: 10 }, (_, i) => vi.fn(async () => `data-${i}`))
|
|
1070
|
-
const definition = Object.fromEntries(
|
|
1071
|
-
fns.map((fn, i) => [`key${i}`, createAsync(fn)])
|
|
1072
|
-
)
|
|
1073
|
-
const store = createStore(definition)
|
|
1074
|
-
await Promise.all(fns.map((_, i) => store.fetch(`key${i}`)))
|
|
1075
|
-
fns.forEach((_, i) => {
|
|
1076
|
-
expect((store.getState() as Record<string, { data: string }>)[`key${i}`].data).toBe(`data-${i}`)
|
|
1077
|
-
})
|
|
1078
|
-
})
|
|
1079
|
-
|
|
1080
|
-
it('fetch called before previous fetch resolves — no state corruption', async () => {
|
|
1081
|
-
const slow = deferred<string>()
|
|
1082
|
-
let call = 0
|
|
1083
|
-
const store = createStore({
|
|
1084
|
-
data: createAsync(async () => {
|
|
1085
|
-
call++
|
|
1086
|
-
if (call === 1) return slow.promise
|
|
1087
|
-
return 'second'
|
|
1088
|
-
})
|
|
1089
|
-
})
|
|
1090
|
-
|
|
1091
|
-
store.fetch('data') // in flight
|
|
1092
|
-
await store.fetch('data') // second
|
|
1093
|
-
|
|
1094
|
-
expect(store.getState().data.data).toBe('second')
|
|
1095
|
-
expect(store.getState().data.status).toBe('success')
|
|
1096
|
-
slow.resolve('first')
|
|
1097
|
-
await wait(10)
|
|
1098
|
-
expect(store.getState().data.data).toBe('second') // not corrupted
|
|
1099
|
-
})
|
|
1100
|
-
})
|