@livestore/react 0.4.0-dev.20 → 0.4.0-dev.21
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/dist/.tsbuildinfo +1 -1
- package/dist/LiveStoreContext.d.ts +27 -0
- package/dist/LiveStoreContext.d.ts.map +1 -1
- package/dist/LiveStoreContext.js +18 -0
- package/dist/LiveStoreContext.js.map +1 -1
- package/dist/LiveStoreProvider.d.ts +9 -2
- package/dist/LiveStoreProvider.d.ts.map +1 -1
- package/dist/LiveStoreProvider.js +2 -1
- package/dist/LiveStoreProvider.js.map +1 -1
- package/dist/experimental/multi-store/StoreRegistry.d.ts +60 -16
- package/dist/experimental/multi-store/StoreRegistry.d.ts.map +1 -1
- package/dist/experimental/multi-store/StoreRegistry.js +125 -216
- package/dist/experimental/multi-store/StoreRegistry.js.map +1 -1
- package/dist/experimental/multi-store/StoreRegistry.test.js +224 -307
- package/dist/experimental/multi-store/StoreRegistry.test.js.map +1 -1
- package/dist/experimental/multi-store/types.d.ts +4 -23
- package/dist/experimental/multi-store/types.d.ts.map +1 -1
- package/dist/experimental/multi-store/useStore.d.ts +1 -1
- package/dist/experimental/multi-store/useStore.d.ts.map +1 -1
- package/dist/experimental/multi-store/useStore.js +5 -10
- package/dist/experimental/multi-store/useStore.js.map +1 -1
- package/dist/experimental/multi-store/useStore.test.js +95 -41
- package/dist/experimental/multi-store/useStore.test.js.map +1 -1
- package/dist/useClientDocument.d.ts +33 -0
- package/dist/useClientDocument.d.ts.map +1 -1
- package/dist/useClientDocument.js.map +1 -1
- package/dist/useStore.d.ts +51 -0
- package/dist/useStore.d.ts.map +1 -1
- package/dist/useStore.js +51 -0
- package/dist/useStore.js.map +1 -1
- package/package.json +6 -6
- package/src/LiveStoreContext.ts +27 -0
- package/src/LiveStoreProvider.tsx +9 -0
- package/src/experimental/multi-store/StoreRegistry.test.ts +236 -349
- package/src/experimental/multi-store/StoreRegistry.ts +171 -265
- package/src/experimental/multi-store/types.ts +31 -49
- package/src/experimental/multi-store/useStore.test.tsx +120 -48
- package/src/experimental/multi-store/useStore.ts +5 -13
- package/src/useClientDocument.ts +35 -0
- package/src/useStore.ts +51 -0
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
import { makeInMemoryAdapter } from '@livestore/adapter-web'
|
|
2
|
+
import { UnknownError } from '@livestore/common'
|
|
2
3
|
import { StoreInternalsSymbol } from '@livestore/livestore'
|
|
3
|
-
import {
|
|
4
|
+
import { sleep } from '@livestore/utils'
|
|
5
|
+
import { Effect } from '@livestore/utils/effect'
|
|
6
|
+
import { describe, expect, it } from 'vitest'
|
|
4
7
|
import { schema } from '../../__tests__/fixture.tsx'
|
|
5
|
-
import {
|
|
8
|
+
import { StoreRegistry } from './StoreRegistry.ts'
|
|
6
9
|
import { storeOptions } from './storeOptions.ts'
|
|
7
10
|
import type { CachedStoreOptions } from './types.ts'
|
|
8
11
|
|
|
9
12
|
describe('StoreRegistry', () => {
|
|
10
|
-
|
|
11
|
-
vi.clearAllTimers()
|
|
12
|
-
vi.useRealTimers()
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
it('returns a Promise when the store is loading', async () => {
|
|
13
|
+
it('returns a promise when the store is loading', async () => {
|
|
16
14
|
const registry = new StoreRegistry()
|
|
17
|
-
const result = registry.
|
|
15
|
+
const result = registry.getOrLoadPromise(testStoreOptions())
|
|
18
16
|
|
|
19
17
|
expect(result).toBeInstanceOf(Promise)
|
|
20
18
|
|
|
@@ -26,12 +24,12 @@ describe('StoreRegistry', () => {
|
|
|
26
24
|
it('returns cached store synchronously after first load resolves', async () => {
|
|
27
25
|
const registry = new StoreRegistry()
|
|
28
26
|
|
|
29
|
-
const initial = registry.
|
|
27
|
+
const initial = registry.getOrLoadPromise(testStoreOptions())
|
|
30
28
|
expect(initial).toBeInstanceOf(Promise)
|
|
31
29
|
|
|
32
30
|
const store = await initial
|
|
33
31
|
|
|
34
|
-
const cached = registry.
|
|
32
|
+
const cached = registry.getOrLoadPromise(testStoreOptions())
|
|
35
33
|
expect(cached).toBe(store)
|
|
36
34
|
expect(cached).not.toBeInstanceOf(Promise)
|
|
37
35
|
|
|
@@ -39,12 +37,12 @@ describe('StoreRegistry', () => {
|
|
|
39
37
|
await store.shutdownPromise()
|
|
40
38
|
})
|
|
41
39
|
|
|
42
|
-
it('reuses the same promise for concurrent
|
|
40
|
+
it('reuses the same promise for concurrent getOrLoadPromise calls while loading', async () => {
|
|
43
41
|
const registry = new StoreRegistry()
|
|
44
42
|
const options = testStoreOptions()
|
|
45
43
|
|
|
46
|
-
const first = registry.
|
|
47
|
-
const second = registry.
|
|
44
|
+
const first = registry.getOrLoadPromise(options)
|
|
45
|
+
const second = registry.getOrLoadPromise(options)
|
|
48
46
|
|
|
49
47
|
// Both should be the same promise
|
|
50
48
|
expect(first).toBe(second)
|
|
@@ -59,137 +57,70 @@ describe('StoreRegistry', () => {
|
|
|
59
57
|
await store.shutdownPromise()
|
|
60
58
|
})
|
|
61
59
|
|
|
62
|
-
it('
|
|
60
|
+
it('throws synchronously and rethrows on subsequent calls for sync failures', () => {
|
|
63
61
|
const registry = new StoreRegistry()
|
|
64
62
|
|
|
65
|
-
// Create an invalid adapter that will cause an error
|
|
66
63
|
const badOptions = testStoreOptions({
|
|
67
64
|
// @ts-expect-error - intentionally passing invalid adapter to trigger error
|
|
68
65
|
adapter: null,
|
|
69
66
|
})
|
|
70
67
|
|
|
71
|
-
|
|
68
|
+
// First call throws synchronously
|
|
69
|
+
expect(() => registry.getOrLoadPromise(badOptions)).toThrow()
|
|
72
70
|
|
|
73
|
-
// Subsequent call should throw
|
|
74
|
-
expect(() => registry.
|
|
71
|
+
// Subsequent call should also throw synchronously (cached error)
|
|
72
|
+
expect(() => registry.getOrLoadPromise(badOptions)).toThrow()
|
|
75
73
|
})
|
|
76
74
|
|
|
77
|
-
it('
|
|
78
|
-
vi.useFakeTimers()
|
|
75
|
+
it('caches and rethrows rejection on subsequent calls for async failures', async () => {
|
|
79
76
|
const registry = new StoreRegistry()
|
|
80
|
-
const unusedCacheTime = 25
|
|
81
|
-
const options = testStoreOptions({ unusedCacheTime })
|
|
82
|
-
|
|
83
|
-
const store = await registry.getOrLoad(options)
|
|
84
|
-
|
|
85
|
-
// Store should be cached
|
|
86
|
-
expect(registry.getOrLoad(options)).toBe(store)
|
|
87
|
-
|
|
88
|
-
// Advance time to trigger disposal
|
|
89
|
-
await vi.advanceTimersByTimeAsync(unusedCacheTime)
|
|
90
|
-
|
|
91
|
-
// After disposal, store should be removed
|
|
92
|
-
// The store is removed from cache, so next getOrLoad creates a new one
|
|
93
|
-
const nextStore = await registry.getOrLoad(options)
|
|
94
|
-
|
|
95
|
-
// Should be a different store instance
|
|
96
|
-
expect(nextStore).not.toBe(store)
|
|
97
|
-
expect(nextStore[StoreInternalsSymbol].clientSession.debugInstanceId).toBeDefined()
|
|
98
|
-
|
|
99
|
-
// Clean up the second store (first one was disposed)
|
|
100
|
-
await nextStore.shutdownPromise()
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
it('keeps the longest unusedCacheTime seen for a store when options vary across calls', async () => {
|
|
104
|
-
vi.useFakeTimers()
|
|
105
|
-
const registry = new StoreRegistry()
|
|
106
|
-
|
|
107
|
-
const options = testStoreOptions({ unusedCacheTime: 10 })
|
|
108
|
-
const unsubscribe = registry.subscribe(options.storeId, () => {})
|
|
109
|
-
|
|
110
|
-
const store = await registry.getOrLoad(options)
|
|
111
|
-
|
|
112
|
-
// Call with longer unusedCacheTime
|
|
113
|
-
await registry.getOrLoad(testStoreOptions({ unusedCacheTime: 100 }))
|
|
114
77
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
expect(registry.getOrLoad(options)).toBe(store)
|
|
122
|
-
|
|
123
|
-
// After the full 100ms, store should be disposed
|
|
124
|
-
await vi.advanceTimersByTimeAsync(1)
|
|
125
|
-
|
|
126
|
-
// Next getOrLoad should create a new store
|
|
127
|
-
const nextStore = await registry.getOrLoad(options)
|
|
128
|
-
expect(nextStore).not.toBe(store)
|
|
129
|
-
|
|
130
|
-
// Clean up the second store (first one was disposed)
|
|
131
|
-
await nextStore.shutdownPromise()
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
it('preload does not throw', async () => {
|
|
135
|
-
const registry = new StoreRegistry()
|
|
136
|
-
|
|
137
|
-
// Create invalid options that would cause an error
|
|
78
|
+
// Create an adapter that fails asynchronously (after yielding to the event loop)
|
|
79
|
+
const failingAdapter = () =>
|
|
80
|
+
Effect.gen(function* () {
|
|
81
|
+
yield* Effect.sleep(0) // Force async execution
|
|
82
|
+
return yield* UnknownError.make({ cause: new Error('Async failure') })
|
|
83
|
+
})
|
|
138
84
|
const badOptions = testStoreOptions({
|
|
139
|
-
|
|
140
|
-
adapter: null,
|
|
85
|
+
adapter: failingAdapter,
|
|
141
86
|
})
|
|
142
87
|
|
|
143
|
-
//
|
|
144
|
-
await expect(registry.
|
|
88
|
+
// First call returns a promise that rejects
|
|
89
|
+
await expect(registry.getOrLoadPromise(badOptions)).rejects.toThrow()
|
|
145
90
|
|
|
146
|
-
//
|
|
147
|
-
expect(() => registry.
|
|
91
|
+
// Subsequent call should throw the cached error synchronously (RcMap caches failures)
|
|
92
|
+
expect(() => registry.getOrLoadPromise(badOptions)).toThrow()
|
|
148
93
|
})
|
|
149
94
|
|
|
150
|
-
it('
|
|
151
|
-
vi.useFakeTimers()
|
|
95
|
+
it('throws the same error instance on multiple calls after failure', async () => {
|
|
152
96
|
const registry = new StoreRegistry()
|
|
153
|
-
const options = testStoreOptions({ unusedCacheTime: Number.POSITIVE_INFINITY })
|
|
154
|
-
|
|
155
|
-
const store = await registry.getOrLoad(options)
|
|
156
97
|
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
// Store should still be cached (not disposed)
|
|
164
|
-
expect(registry.getOrLoad(options)).toBe(store)
|
|
165
|
-
|
|
166
|
-
// Clean up manually
|
|
167
|
-
await store.shutdownPromise()
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
it('throws the same error instance on multiple synchronous calls after failure', async () => {
|
|
171
|
-
const registry = new StoreRegistry()
|
|
98
|
+
// Create an adapter that fails asynchronously
|
|
99
|
+
const failingAdapter = () =>
|
|
100
|
+
Effect.gen(function* () {
|
|
101
|
+
yield* Effect.sleep(0) // Force async execution
|
|
102
|
+
return yield* UnknownError.make({ cause: new Error('Async failure') })
|
|
103
|
+
})
|
|
172
104
|
|
|
173
105
|
const badOptions = testStoreOptions({
|
|
174
|
-
|
|
175
|
-
adapter: null,
|
|
106
|
+
adapter: failingAdapter,
|
|
176
107
|
})
|
|
177
108
|
|
|
178
109
|
// Wait for the first failure
|
|
179
|
-
await expect(registry.
|
|
110
|
+
await expect(registry.getOrLoadPromise(badOptions)).rejects.toThrow()
|
|
180
111
|
|
|
181
|
-
// Capture the errors from subsequent
|
|
112
|
+
// Capture the errors from subsequent calls
|
|
182
113
|
let error1: unknown
|
|
183
114
|
let error2: unknown
|
|
184
115
|
|
|
185
116
|
try {
|
|
186
|
-
registry.
|
|
117
|
+
registry.getOrLoadPromise(badOptions)
|
|
187
118
|
} catch (err) {
|
|
188
119
|
error1 = err
|
|
189
120
|
}
|
|
190
121
|
|
|
191
122
|
try {
|
|
192
|
-
registry.
|
|
123
|
+
registry.getOrLoadPromise(badOptions)
|
|
193
124
|
} catch (err) {
|
|
194
125
|
error2 = err
|
|
195
126
|
}
|
|
@@ -199,197 +130,224 @@ describe('StoreRegistry', () => {
|
|
|
199
130
|
expect(error1).toBe(error2)
|
|
200
131
|
})
|
|
201
132
|
|
|
202
|
-
it('
|
|
203
|
-
const
|
|
133
|
+
it('disposes store after unusedCacheTime expires', async () => {
|
|
134
|
+
const unusedCacheTime = 25
|
|
135
|
+
const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
|
|
204
136
|
const options = testStoreOptions()
|
|
205
137
|
|
|
206
|
-
|
|
207
|
-
const listener = () => {
|
|
208
|
-
notificationCount++
|
|
209
|
-
}
|
|
138
|
+
const store = await registry.getOrLoadPromise(options)
|
|
210
139
|
|
|
211
|
-
|
|
140
|
+
// Store should be cached
|
|
141
|
+
expect(registry.getOrLoadPromise(options)).toBe(store)
|
|
212
142
|
|
|
213
|
-
//
|
|
214
|
-
|
|
143
|
+
// Wait for disposal
|
|
144
|
+
await sleep(unusedCacheTime + 50)
|
|
215
145
|
|
|
216
|
-
//
|
|
217
|
-
|
|
146
|
+
// After disposal, store should be removed
|
|
147
|
+
// The store is removed from cache, so next getOrLoadStore creates a new one
|
|
148
|
+
const nextStore = await registry.getOrLoadPromise(options)
|
|
218
149
|
|
|
219
|
-
|
|
150
|
+
// Should be a different store instance
|
|
151
|
+
expect(nextStore).not.toBe(store)
|
|
152
|
+
expect(nextStore[StoreInternalsSymbol].clientSession.debugInstanceId).toBeDefined()
|
|
220
153
|
|
|
221
|
-
//
|
|
222
|
-
|
|
154
|
+
// Clean up the second store (first one was disposed)
|
|
155
|
+
await nextStore.shutdownPromise()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('does not dispose when unusedCacheTime is Infinity', async () => {
|
|
159
|
+
const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime: Number.POSITIVE_INFINITY } })
|
|
160
|
+
const options = testStoreOptions()
|
|
223
161
|
|
|
224
|
-
|
|
162
|
+
const store = await registry.getOrLoadPromise(options)
|
|
225
163
|
|
|
226
|
-
//
|
|
164
|
+
// Store should be cached
|
|
165
|
+
expect(registry.getOrLoadPromise(options)).toBe(store)
|
|
166
|
+
|
|
167
|
+
// Wait a reasonable duration to verify no disposal
|
|
168
|
+
await sleep(100)
|
|
169
|
+
|
|
170
|
+
// Store should still be cached (not disposed)
|
|
171
|
+
expect(registry.getOrLoadPromise(options)).toBe(store)
|
|
172
|
+
|
|
173
|
+
// Clean up manually
|
|
227
174
|
await store.shutdownPromise()
|
|
228
175
|
})
|
|
229
176
|
|
|
230
|
-
it('
|
|
231
|
-
vi.useFakeTimers()
|
|
232
|
-
const registry = new StoreRegistry()
|
|
177
|
+
it('schedules disposal if store becomes unused during loading', async () => {
|
|
233
178
|
const unusedCacheTime = 50
|
|
234
|
-
const
|
|
179
|
+
const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
|
|
180
|
+
const options = testStoreOptions()
|
|
235
181
|
|
|
236
|
-
|
|
182
|
+
// Start loading without any retain
|
|
183
|
+
const storePromise = registry.getOrLoadPromise(options)
|
|
237
184
|
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
const unsubscribe = registry.subscribe(options.storeId, () => {})
|
|
241
|
-
unsubscribe()
|
|
242
|
-
}
|
|
185
|
+
// Wait for store to load (no retain registered)
|
|
186
|
+
const store = await storePromise
|
|
243
187
|
|
|
244
|
-
//
|
|
245
|
-
await
|
|
188
|
+
// Since there were no retain when loading completed, disposal should be scheduled
|
|
189
|
+
await sleep(unusedCacheTime + 50)
|
|
246
190
|
|
|
247
|
-
// Store should be disposed
|
|
248
|
-
const nextStore = await registry.
|
|
191
|
+
// Store should be disposed
|
|
192
|
+
const nextStore = await registry.getOrLoadPromise(options)
|
|
249
193
|
expect(nextStore).not.toBe(store)
|
|
250
194
|
|
|
251
195
|
await nextStore.shutdownPromise()
|
|
252
196
|
})
|
|
253
197
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
errorListenerCalled = true
|
|
263
|
-
throw new Error('Listener error')
|
|
264
|
-
}
|
|
198
|
+
// This test is skipped because Effect doesn't yet support different `idleTimeToLive` values for each resource in `RcMap`
|
|
199
|
+
// See https://github.com/livestorejs/livestore/issues/917
|
|
200
|
+
it.skip('allows call-site options to override default options', async () => {
|
|
201
|
+
const registry = new StoreRegistry({
|
|
202
|
+
defaultOptions: {
|
|
203
|
+
unusedCacheTime: 1000, // Default is long
|
|
204
|
+
},
|
|
205
|
+
})
|
|
265
206
|
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
}
|
|
207
|
+
const options = testStoreOptions({
|
|
208
|
+
unusedCacheTime: 10, // Override with shorter time
|
|
209
|
+
})
|
|
269
210
|
|
|
270
|
-
registry.
|
|
271
|
-
registry.subscribe(options.storeId, goodListener)
|
|
211
|
+
const store = await registry.getOrLoadPromise(options)
|
|
272
212
|
|
|
273
|
-
//
|
|
274
|
-
|
|
213
|
+
// Wait for the override time (10ms)
|
|
214
|
+
await sleep(10)
|
|
275
215
|
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
expect(
|
|
216
|
+
// Should be disposed according to the override time, not default
|
|
217
|
+
const nextStore = await registry.getOrLoadPromise(options)
|
|
218
|
+
expect(nextStore).not.toBe(store)
|
|
279
219
|
|
|
280
|
-
await
|
|
220
|
+
await nextStore.shutdownPromise()
|
|
281
221
|
})
|
|
282
222
|
|
|
283
|
-
|
|
223
|
+
// This test is skipped because we don't yet support dynamic `unusedCacheTime` updates for cached stores.
|
|
224
|
+
// See https://github.com/livestorejs/livestore/issues/918
|
|
225
|
+
it.skip('keeps the longest unusedCacheTime seen for a store when options vary across calls', async () => {
|
|
284
226
|
const registry = new StoreRegistry()
|
|
285
|
-
const options = testStoreOptions()
|
|
286
227
|
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
notificationCount++
|
|
290
|
-
}
|
|
228
|
+
const options = testStoreOptions({ unusedCacheTime: 10 })
|
|
229
|
+
const release = registry.retain(options)
|
|
291
230
|
|
|
292
|
-
|
|
293
|
-
const unsubscribe = registry.subscribe(options.storeId, listener)
|
|
231
|
+
const store = await registry.getOrLoadPromise(options)
|
|
294
232
|
|
|
295
|
-
//
|
|
296
|
-
|
|
233
|
+
// Call with longer unusedCacheTime
|
|
234
|
+
await registry.getOrLoadPromise(testStoreOptions({ unusedCacheTime: 100 }))
|
|
297
235
|
|
|
298
|
-
|
|
299
|
-
expect(notificationCount).toBeGreaterThan(0)
|
|
236
|
+
release()
|
|
300
237
|
|
|
301
|
-
|
|
238
|
+
// After 99ms, store should still be alive (100ms unusedCacheTime used)
|
|
239
|
+
await sleep(99)
|
|
302
240
|
|
|
303
|
-
//
|
|
304
|
-
expect(
|
|
241
|
+
// Store should still be cached
|
|
242
|
+
expect(registry.getOrLoadPromise(options)).toBe(store)
|
|
305
243
|
|
|
306
|
-
|
|
244
|
+
// After the full 100ms, store should be disposed
|
|
245
|
+
await sleep(1)
|
|
307
246
|
|
|
308
|
-
//
|
|
309
|
-
await
|
|
247
|
+
// Next getOrLoadStore should create a new store
|
|
248
|
+
const nextStore = await registry.getOrLoadPromise(options)
|
|
249
|
+
expect(nextStore).not.toBe(store)
|
|
250
|
+
|
|
251
|
+
// Clean up the second store (first one was disposed)
|
|
252
|
+
await nextStore.shutdownPromise()
|
|
310
253
|
})
|
|
311
254
|
|
|
312
|
-
it('
|
|
313
|
-
vi.useFakeTimers()
|
|
255
|
+
it('preload does not throw', async () => {
|
|
314
256
|
const registry = new StoreRegistry()
|
|
315
|
-
const unusedCacheTime = 50
|
|
316
|
-
const options = testStoreOptions({ unusedCacheTime })
|
|
317
257
|
|
|
318
|
-
|
|
258
|
+
// Create invalid options that would cause an error
|
|
259
|
+
const badOptions = testStoreOptions({
|
|
260
|
+
// @ts-expect-error - intentionally passing invalid adapter to trigger error
|
|
261
|
+
adapter: null,
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
// preload should not throw
|
|
265
|
+
await expect(registry.preload(badOptions)).resolves.toBeUndefined()
|
|
319
266
|
|
|
320
|
-
//
|
|
321
|
-
|
|
267
|
+
// But subsequent getOrLoadStore should throw the cached error
|
|
268
|
+
expect(() => registry.getOrLoadPromise(badOptions)).toThrow()
|
|
269
|
+
})
|
|
322
270
|
|
|
323
|
-
|
|
324
|
-
const
|
|
271
|
+
it('handles rapid retain/release cycles without errors', async () => {
|
|
272
|
+
const unusedCacheTime = 50
|
|
273
|
+
const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
|
|
274
|
+
const options = testStoreOptions()
|
|
325
275
|
|
|
326
|
-
|
|
327
|
-
await vi.advanceTimersByTimeAsync(5)
|
|
276
|
+
const store = await registry.getOrLoadPromise(options)
|
|
328
277
|
|
|
329
|
-
//
|
|
330
|
-
|
|
278
|
+
// Rapidly retain and release multiple times
|
|
279
|
+
for (let i = 0; i < 10; i++) {
|
|
280
|
+
const release = registry.retain(options)
|
|
281
|
+
release()
|
|
282
|
+
}
|
|
331
283
|
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
await vi.advanceTimersByTimeAsync(unusedCacheTime)
|
|
284
|
+
// Wait for disposal to trigger
|
|
285
|
+
await sleep(unusedCacheTime + 50)
|
|
335
286
|
|
|
336
|
-
//
|
|
337
|
-
const nextStore = await registry.
|
|
287
|
+
// Store should be disposed after the last release
|
|
288
|
+
const nextStore = await registry.getOrLoadPromise(options)
|
|
338
289
|
expect(nextStore).not.toBe(store)
|
|
339
290
|
|
|
340
291
|
await nextStore.shutdownPromise()
|
|
341
292
|
})
|
|
342
293
|
|
|
343
|
-
it('
|
|
344
|
-
vi.useFakeTimers()
|
|
345
|
-
const registry = new StoreRegistry()
|
|
294
|
+
it('cancels disposal when new retain', async () => {
|
|
346
295
|
const unusedCacheTime = 50
|
|
347
|
-
const
|
|
296
|
+
const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
|
|
297
|
+
const options = testStoreOptions()
|
|
348
298
|
|
|
349
|
-
|
|
350
|
-
const storePromise = registry.getOrLoad(options)
|
|
299
|
+
const store = await registry.getOrLoadPromise(options)
|
|
351
300
|
|
|
352
|
-
// Wait
|
|
353
|
-
|
|
301
|
+
// Wait almost to disposal threshold
|
|
302
|
+
await sleep(unusedCacheTime - 5)
|
|
354
303
|
|
|
355
|
-
//
|
|
356
|
-
|
|
304
|
+
// Add a new retain before disposal triggers
|
|
305
|
+
const release = registry.retain(options)
|
|
357
306
|
|
|
358
|
-
//
|
|
359
|
-
|
|
307
|
+
// Complete the original unusedCacheTime
|
|
308
|
+
await sleep(5)
|
|
309
|
+
|
|
310
|
+
// Store should not have been disposed because we added a retain
|
|
311
|
+
expect(registry.getOrLoadPromise(options)).toBe(store)
|
|
312
|
+
|
|
313
|
+
// Clean up
|
|
314
|
+
release()
|
|
315
|
+
await sleep(unusedCacheTime + 50)
|
|
316
|
+
|
|
317
|
+
// Now it should be disposed
|
|
318
|
+
const nextStore = await registry.getOrLoadPromise(options)
|
|
360
319
|
expect(nextStore).not.toBe(store)
|
|
361
320
|
|
|
362
321
|
await nextStore.shutdownPromise()
|
|
363
322
|
})
|
|
364
323
|
|
|
365
324
|
it('aborts loading when disposal fires while store is still loading', async () => {
|
|
366
|
-
vi.useFakeTimers()
|
|
367
|
-
const registry = new StoreRegistry()
|
|
368
325
|
const unusedCacheTime = 10
|
|
369
|
-
const
|
|
326
|
+
const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
|
|
327
|
+
const options = testStoreOptions()
|
|
370
328
|
|
|
371
|
-
//
|
|
372
|
-
const
|
|
329
|
+
// Retain briefly to trigger getOrLoadStore and then release
|
|
330
|
+
const release = registry.retain(options)
|
|
373
331
|
|
|
374
|
-
// Start loading
|
|
375
|
-
const loadPromise = registry.
|
|
332
|
+
// Start loading
|
|
333
|
+
const loadPromise = registry.getOrLoadPromise(options)
|
|
376
334
|
|
|
377
335
|
// Attach a catch handler to prevent unhandled rejection when the load is aborted
|
|
378
336
|
const abortedPromise = (loadPromise as Promise<unknown>).catch(() => {
|
|
379
337
|
// Expected: load was aborted by disposal
|
|
380
338
|
})
|
|
381
339
|
|
|
382
|
-
//
|
|
383
|
-
|
|
340
|
+
// Release immediately, which schedules disposal
|
|
341
|
+
release()
|
|
384
342
|
|
|
385
|
-
//
|
|
386
|
-
await
|
|
343
|
+
// Wait for disposal to trigger
|
|
344
|
+
await sleep(unusedCacheTime + 50)
|
|
387
345
|
|
|
388
346
|
// Wait for the abort to complete
|
|
389
347
|
await abortedPromise
|
|
390
348
|
|
|
391
|
-
// After abort, a new
|
|
392
|
-
const freshLoadPromise = registry.
|
|
349
|
+
// After abort, a new getOrLoadStore should start a fresh load
|
|
350
|
+
const freshLoadPromise = registry.getOrLoadPromise(options)
|
|
393
351
|
|
|
394
352
|
// This should be a new promise (not the aborted one)
|
|
395
353
|
expect(freshLoadPromise).toBeInstanceOf(Promise)
|
|
@@ -402,174 +360,104 @@ describe('StoreRegistry', () => {
|
|
|
402
360
|
await store.shutdownPromise()
|
|
403
361
|
})
|
|
404
362
|
|
|
405
|
-
it('
|
|
406
|
-
vi.useFakeTimers()
|
|
407
|
-
const registry = new StoreRegistry()
|
|
363
|
+
it('retain keeps store alive past unusedCacheTime', async () => {
|
|
408
364
|
const unusedCacheTime = 50
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
// Start loading and immediately unsubscribe to schedule disposal
|
|
412
|
-
const unsub1 = registry.subscribe(options.storeId, () => {})
|
|
413
|
-
const loadPromise = registry.getOrLoad(options)
|
|
414
|
-
unsub1()
|
|
415
|
-
|
|
416
|
-
// Advance time partially (before disposal fires)
|
|
417
|
-
await vi.advanceTimersByTimeAsync(unusedCacheTime - 10)
|
|
365
|
+
const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
|
|
366
|
+
const options = testStoreOptions()
|
|
418
367
|
|
|
419
|
-
//
|
|
420
|
-
const
|
|
368
|
+
// Load the store
|
|
369
|
+
const store = await registry.getOrLoadPromise(options)
|
|
421
370
|
|
|
422
|
-
//
|
|
423
|
-
|
|
371
|
+
// Retain the store before disposal could fire
|
|
372
|
+
const release = registry.retain(options)
|
|
424
373
|
|
|
425
|
-
//
|
|
426
|
-
|
|
374
|
+
// Wait past the unusedCacheTime
|
|
375
|
+
await sleep(unusedCacheTime + 50)
|
|
427
376
|
|
|
428
|
-
//
|
|
429
|
-
const cachedStore = registry.
|
|
377
|
+
// Store should still be cached because retain keeps it alive
|
|
378
|
+
const cachedStore = registry.getOrLoadPromise(options)
|
|
430
379
|
expect(cachedStore).toBe(store)
|
|
431
380
|
|
|
432
|
-
|
|
381
|
+
release()
|
|
433
382
|
await store.shutdownPromise()
|
|
434
383
|
})
|
|
435
384
|
|
|
436
385
|
it('manages multiple stores with different IDs independently', async () => {
|
|
437
|
-
|
|
438
|
-
const registry = new StoreRegistry()
|
|
386
|
+
const unusedCacheTime = 50
|
|
387
|
+
const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
|
|
439
388
|
|
|
440
|
-
const options1 = testStoreOptions({ storeId: 'store-1'
|
|
441
|
-
const options2 = testStoreOptions({ storeId: 'store-2'
|
|
389
|
+
const options1 = testStoreOptions({ storeId: 'store-1' })
|
|
390
|
+
const options2 = testStoreOptions({ storeId: 'store-2' })
|
|
442
391
|
|
|
443
|
-
const store1 = await registry.
|
|
444
|
-
const store2 = await registry.
|
|
392
|
+
const store1 = await registry.getOrLoadPromise(options1)
|
|
393
|
+
const store2 = await registry.getOrLoadPromise(options2)
|
|
445
394
|
|
|
446
395
|
// Should be different store instances
|
|
447
396
|
expect(store1).not.toBe(store2)
|
|
448
397
|
|
|
449
398
|
// Both should be cached independently
|
|
450
|
-
expect(registry.
|
|
451
|
-
expect(registry.
|
|
399
|
+
expect(registry.getOrLoadPromise(options1)).toBe(store1)
|
|
400
|
+
expect(registry.getOrLoadPromise(options2)).toBe(store2)
|
|
452
401
|
|
|
453
|
-
//
|
|
454
|
-
await
|
|
402
|
+
// Wait for both stores to be disposed
|
|
403
|
+
await sleep(unusedCacheTime + 50)
|
|
455
404
|
|
|
456
|
-
//
|
|
457
|
-
const newStore1 = await registry.
|
|
405
|
+
// Both stores should be disposed, so next getOrLoadStore creates new ones
|
|
406
|
+
const newStore1 = await registry.getOrLoadPromise(options1)
|
|
458
407
|
expect(newStore1).not.toBe(store1)
|
|
459
|
-
expect(registry.getOrLoad(options2)).toBe(store2)
|
|
460
408
|
|
|
461
|
-
|
|
462
|
-
const unsub1 = registry.subscribe(options1.storeId, () => {})
|
|
463
|
-
|
|
464
|
-
// Advance remaining time to dispose store2
|
|
465
|
-
await vi.advanceTimersByTimeAsync(50)
|
|
466
|
-
|
|
467
|
-
// store2 should be disposed
|
|
468
|
-
const newStore2 = await registry.getOrLoad(options2)
|
|
409
|
+
const newStore2 = await registry.getOrLoadPromise(options2)
|
|
469
410
|
expect(newStore2).not.toBe(store2)
|
|
470
411
|
|
|
471
|
-
// Subscribe to prevent disposal of newStore2
|
|
472
|
-
const unsub2 = registry.subscribe(options2.storeId, () => {})
|
|
473
|
-
|
|
474
412
|
// Clean up
|
|
475
|
-
unsub1()
|
|
476
|
-
unsub2()
|
|
477
413
|
await newStore1.shutdownPromise()
|
|
478
414
|
await newStore2.shutdownPromise()
|
|
479
415
|
})
|
|
480
416
|
|
|
481
417
|
it('applies default options from constructor', async () => {
|
|
482
|
-
vi.useFakeTimers()
|
|
483
|
-
|
|
484
418
|
const registry = new StoreRegistry({
|
|
485
419
|
defaultOptions: {
|
|
486
|
-
unusedCacheTime:
|
|
420
|
+
unusedCacheTime: 100,
|
|
487
421
|
},
|
|
488
422
|
})
|
|
489
423
|
|
|
490
424
|
const options = testStoreOptions()
|
|
491
425
|
|
|
492
|
-
const store = await registry.
|
|
426
|
+
const store = await registry.getOrLoadPromise(options)
|
|
493
427
|
|
|
494
428
|
// Verify the store loads successfully
|
|
495
429
|
expect(store).toBeDefined()
|
|
496
430
|
expect(store[StoreInternalsSymbol].clientSession.debugInstanceId).toBeDefined()
|
|
497
431
|
|
|
498
|
-
// Verify configured default unusedCacheTime is applied by checking disposal doesn't happen
|
|
499
|
-
await
|
|
432
|
+
// Verify configured default unusedCacheTime is applied by checking disposal doesn't happen before it
|
|
433
|
+
await sleep(50)
|
|
500
434
|
|
|
501
|
-
// Store should still be cached after default
|
|
502
|
-
expect(registry.
|
|
435
|
+
// Store should still be cached after 50ms (default is 100ms)
|
|
436
|
+
expect(registry.getOrLoadPromise(options)).toBe(store)
|
|
503
437
|
|
|
504
438
|
await store.shutdownPromise()
|
|
505
439
|
})
|
|
506
440
|
|
|
507
|
-
it('
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
const
|
|
511
|
-
defaultOptions: {
|
|
512
|
-
unusedCacheTime: 1000, // Default is long
|
|
513
|
-
},
|
|
514
|
-
})
|
|
515
|
-
|
|
516
|
-
const options = testStoreOptions({
|
|
517
|
-
unusedCacheTime: 10, // Override with shorter time
|
|
518
|
-
})
|
|
519
|
-
|
|
520
|
-
const store = await registry.getOrLoad(options)
|
|
521
|
-
|
|
522
|
-
// Advance by the override time (10ms)
|
|
523
|
-
await vi.advanceTimersByTimeAsync(10)
|
|
524
|
-
|
|
525
|
-
// Should be disposed according to the override time, not default
|
|
526
|
-
const nextStore = await registry.getOrLoad(options)
|
|
527
|
-
expect(nextStore).not.toBe(store)
|
|
528
|
-
|
|
529
|
-
await nextStore.shutdownPromise()
|
|
530
|
-
})
|
|
531
|
-
|
|
532
|
-
it('prevents subscriptions to stores that are shutting down', async () => {
|
|
533
|
-
vi.useFakeTimers()
|
|
534
|
-
const registry = new StoreRegistry()
|
|
535
|
-
const unusedCacheTime = 10
|
|
536
|
-
const options = testStoreOptions({ unusedCacheTime })
|
|
441
|
+
it('prevents getOrLoadStore from returning a dying store', async () => {
|
|
442
|
+
const unusedCacheTime = 25
|
|
443
|
+
const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
|
|
444
|
+
const options = testStoreOptions()
|
|
537
445
|
|
|
538
446
|
// Load the store and wait for it to be ready
|
|
539
|
-
const originalStore = await registry.
|
|
447
|
+
const originalStore = await registry.getOrLoadPromise(options)
|
|
540
448
|
|
|
541
449
|
// Verify store is cached
|
|
542
|
-
expect(registry.
|
|
543
|
-
|
|
544
|
-
// Spy on shutdownPromise to detect when shutdown starts
|
|
545
|
-
let shutdownStarted = false
|
|
546
|
-
let shutdownCompleted = false
|
|
547
|
-
const originalShutdownPromise = originalStore.shutdownPromise.bind(originalStore)
|
|
548
|
-
originalStore.shutdownPromise = () => {
|
|
549
|
-
shutdownStarted = true
|
|
550
|
-
return originalShutdownPromise().finally(() => {
|
|
551
|
-
shutdownCompleted = true
|
|
552
|
-
})
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// Use vi.advanceTimersToNextTimer to advance ONLY to the disposal timer firing,
|
|
556
|
-
// then immediately (before microtasks resolve) try to get the store
|
|
557
|
-
vi.advanceTimersToNextTimer()
|
|
450
|
+
expect(registry.getOrLoadPromise(options)).toBe(originalStore)
|
|
558
451
|
|
|
559
|
-
//
|
|
560
|
-
|
|
561
|
-
// 2. shutdown() was called (but it's async, hasn't resolved yet)
|
|
562
|
-
// 3. Cache entry SHOULD have been removed
|
|
452
|
+
// Wait for disposal to trigger
|
|
453
|
+
await sleep(unusedCacheTime + 50)
|
|
563
454
|
|
|
564
|
-
//
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
expect(shutdownCompleted).toBe(false)
|
|
568
|
-
|
|
569
|
-
const storeOrPromise = registry.getOrLoad(options)
|
|
455
|
+
// After disposal, the cache should be cleared
|
|
456
|
+
// Calling getOrLoadStore should start a fresh load (return Promise)
|
|
457
|
+
const storeOrPromise = registry.getOrLoadPromise(options)
|
|
570
458
|
|
|
571
459
|
if (!(storeOrPromise instanceof Promise)) {
|
|
572
|
-
expect.fail('
|
|
460
|
+
expect.fail('getOrLoadStore returned dying store synchronously instead of starting fresh load')
|
|
573
461
|
}
|
|
574
462
|
|
|
575
463
|
const freshStore = await storeOrPromise
|
|
@@ -578,15 +466,15 @@ describe('StoreRegistry', () => {
|
|
|
578
466
|
await freshStore.shutdownPromise()
|
|
579
467
|
})
|
|
580
468
|
|
|
581
|
-
it('warms the cache so subsequent
|
|
469
|
+
it('warms the cache so subsequent getOrLoadStore is synchronous after preload', async () => {
|
|
582
470
|
const registry = new StoreRegistry()
|
|
583
471
|
const options = testStoreOptions()
|
|
584
472
|
|
|
585
473
|
// Preload the store
|
|
586
474
|
await registry.preload(options)
|
|
587
475
|
|
|
588
|
-
// Subsequent
|
|
589
|
-
const store = registry.
|
|
476
|
+
// Subsequent getOrLoadStore should return synchronously (not a Promise)
|
|
477
|
+
const store = registry.getOrLoadPromise(options)
|
|
590
478
|
expect(store).not.toBeInstanceOf(Promise)
|
|
591
479
|
|
|
592
480
|
// TypeScript doesn't narrow the type, so we need to assert
|
|
@@ -598,24 +486,23 @@ describe('StoreRegistry', () => {
|
|
|
598
486
|
await store.shutdownPromise()
|
|
599
487
|
})
|
|
600
488
|
|
|
601
|
-
it('schedules disposal after preload if no
|
|
602
|
-
vi.useFakeTimers()
|
|
603
|
-
const registry = new StoreRegistry()
|
|
489
|
+
it('schedules disposal after preload if no retainers are added', async () => {
|
|
604
490
|
const unusedCacheTime = 50
|
|
605
|
-
const
|
|
491
|
+
const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
|
|
492
|
+
const options = testStoreOptions()
|
|
606
493
|
|
|
607
|
-
// Preload without
|
|
494
|
+
// Preload without retaining
|
|
608
495
|
await registry.preload(options)
|
|
609
496
|
|
|
610
497
|
// Get the store
|
|
611
|
-
const store = registry.
|
|
498
|
+
const store = registry.getOrLoadPromise(options)
|
|
612
499
|
expect(store).not.toBeInstanceOf(Promise)
|
|
613
500
|
|
|
614
|
-
//
|
|
615
|
-
await
|
|
501
|
+
// Wait for disposal to trigger
|
|
502
|
+
await sleep(unusedCacheTime + 50)
|
|
616
503
|
|
|
617
|
-
// Store should be disposed since no
|
|
618
|
-
const nextStore = await registry.
|
|
504
|
+
// Store should be disposed since no retainers were added
|
|
505
|
+
const nextStore = await registry.getOrLoadPromise(options)
|
|
619
506
|
expect(nextStore).not.toBe(store)
|
|
620
507
|
|
|
621
508
|
await nextStore.shutdownPromise()
|