@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.
Files changed (40) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/LiveStoreContext.d.ts +27 -0
  3. package/dist/LiveStoreContext.d.ts.map +1 -1
  4. package/dist/LiveStoreContext.js +18 -0
  5. package/dist/LiveStoreContext.js.map +1 -1
  6. package/dist/LiveStoreProvider.d.ts +9 -2
  7. package/dist/LiveStoreProvider.d.ts.map +1 -1
  8. package/dist/LiveStoreProvider.js +2 -1
  9. package/dist/LiveStoreProvider.js.map +1 -1
  10. package/dist/experimental/multi-store/StoreRegistry.d.ts +60 -16
  11. package/dist/experimental/multi-store/StoreRegistry.d.ts.map +1 -1
  12. package/dist/experimental/multi-store/StoreRegistry.js +125 -216
  13. package/dist/experimental/multi-store/StoreRegistry.js.map +1 -1
  14. package/dist/experimental/multi-store/StoreRegistry.test.js +224 -307
  15. package/dist/experimental/multi-store/StoreRegistry.test.js.map +1 -1
  16. package/dist/experimental/multi-store/types.d.ts +4 -23
  17. package/dist/experimental/multi-store/types.d.ts.map +1 -1
  18. package/dist/experimental/multi-store/useStore.d.ts +1 -1
  19. package/dist/experimental/multi-store/useStore.d.ts.map +1 -1
  20. package/dist/experimental/multi-store/useStore.js +5 -10
  21. package/dist/experimental/multi-store/useStore.js.map +1 -1
  22. package/dist/experimental/multi-store/useStore.test.js +95 -41
  23. package/dist/experimental/multi-store/useStore.test.js.map +1 -1
  24. package/dist/useClientDocument.d.ts +33 -0
  25. package/dist/useClientDocument.d.ts.map +1 -1
  26. package/dist/useClientDocument.js.map +1 -1
  27. package/dist/useStore.d.ts +51 -0
  28. package/dist/useStore.d.ts.map +1 -1
  29. package/dist/useStore.js +51 -0
  30. package/dist/useStore.js.map +1 -1
  31. package/package.json +6 -6
  32. package/src/LiveStoreContext.ts +27 -0
  33. package/src/LiveStoreProvider.tsx +9 -0
  34. package/src/experimental/multi-store/StoreRegistry.test.ts +236 -349
  35. package/src/experimental/multi-store/StoreRegistry.ts +171 -265
  36. package/src/experimental/multi-store/types.ts +31 -49
  37. package/src/experimental/multi-store/useStore.test.tsx +120 -48
  38. package/src/experimental/multi-store/useStore.ts +5 -13
  39. package/src/useClientDocument.ts +35 -0
  40. 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 { afterEach, describe, expect, it, vi } from 'vitest'
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 { DEFAULT_UNUSED_CACHE_TIME, StoreRegistry } from './StoreRegistry.ts'
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
- afterEach(() => {
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.getOrLoad(testStoreOptions())
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.getOrLoad(testStoreOptions())
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.getOrLoad(testStoreOptions())
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 getOrLoad calls while loading', async () => {
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.getOrLoad(options)
47
- const second = registry.getOrLoad(options)
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('stores and rethrows the rejection on subsequent getOrLoad calls after a failure', async () => {
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
- await expect(registry.getOrLoad(badOptions)).rejects.toThrow()
68
+ // First call throws synchronously
69
+ expect(() => registry.getOrLoadPromise(badOptions)).toThrow()
72
70
 
73
- // Subsequent call should throw the cached error synchronously
74
- expect(() => registry.getOrLoad(badOptions)).toThrow()
71
+ // Subsequent call should also throw synchronously (cached error)
72
+ expect(() => registry.getOrLoadPromise(badOptions)).toThrow()
75
73
  })
76
74
 
77
- it('disposes store after unusedCacheTime expires', async () => {
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
- unsubscribe()
116
-
117
- // After 99ms, store should still be alive (100ms unusedCacheTime used)
118
- await vi.advanceTimersByTimeAsync(99)
119
-
120
- // Store should still be cached
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
- // @ts-expect-error - intentionally passing invalid adapter to trigger error
140
- adapter: null,
85
+ adapter: failingAdapter,
141
86
  })
142
87
 
143
- // preload should not throw
144
- await expect(registry.preload(badOptions)).resolves.toBeUndefined()
88
+ // First call returns a promise that rejects
89
+ await expect(registry.getOrLoadPromise(badOptions)).rejects.toThrow()
145
90
 
146
- // But subsequent getOrLoad should throw the cached error
147
- expect(() => registry.getOrLoad(badOptions)).toThrow()
91
+ // Subsequent call should throw the cached error synchronously (RcMap caches failures)
92
+ expect(() => registry.getOrLoadPromise(badOptions)).toThrow()
148
93
  })
149
94
 
150
- it('does not dispose when unusedCacheTime is Infinity', async () => {
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
- // Store should be cached
158
- expect(registry.getOrLoad(options)).toBe(store)
159
-
160
- // Advance time by a very long duration
161
- await vi.advanceTimersByTimeAsync(1000000)
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
- // @ts-expect-error - intentionally passing invalid adapter to trigger error
175
- adapter: null,
106
+ adapter: failingAdapter,
176
107
  })
177
108
 
178
109
  // Wait for the first failure
179
- await expect(registry.getOrLoad(badOptions)).rejects.toThrow()
110
+ await expect(registry.getOrLoadPromise(badOptions)).rejects.toThrow()
180
111
 
181
- // Capture the errors from subsequent synchronous calls
112
+ // Capture the errors from subsequent calls
182
113
  let error1: unknown
183
114
  let error2: unknown
184
115
 
185
116
  try {
186
- registry.getOrLoad(badOptions)
117
+ registry.getOrLoadPromise(badOptions)
187
118
  } catch (err) {
188
119
  error1 = err
189
120
  }
190
121
 
191
122
  try {
192
- registry.getOrLoad(badOptions)
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('notifies subscribers when store state changes', async () => {
203
- const registry = new StoreRegistry()
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
- let notificationCount = 0
207
- const listener = () => {
208
- notificationCount++
209
- }
138
+ const store = await registry.getOrLoadPromise(options)
210
139
 
211
- const unsubscribe = registry.subscribe(options.storeId, listener)
140
+ // Store should be cached
141
+ expect(registry.getOrLoadPromise(options)).toBe(store)
212
142
 
213
- // Start loading the store
214
- const storePromise = registry.getOrLoad(options)
143
+ // Wait for disposal
144
+ await sleep(unusedCacheTime + 50)
215
145
 
216
- // Listener should be called when store starts loading
217
- expect(notificationCount).toBe(1)
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
- const store = await storePromise
150
+ // Should be a different store instance
151
+ expect(nextStore).not.toBe(store)
152
+ expect(nextStore[StoreInternalsSymbol].clientSession.debugInstanceId).toBeDefined()
220
153
 
221
- // Listener should be called when store loads successfully
222
- expect(notificationCount).toBe(2)
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
- unsubscribe()
162
+ const store = await registry.getOrLoadPromise(options)
225
163
 
226
- // Clean up
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('handles rapid subscribe/unsubscribe cycles without errors', async () => {
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 options = testStoreOptions({ unusedCacheTime })
179
+ const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
180
+ const options = testStoreOptions()
235
181
 
236
- const store = await registry.getOrLoad(options)
182
+ // Start loading without any retain
183
+ const storePromise = registry.getOrLoadPromise(options)
237
184
 
238
- // Rapidly subscribe and unsubscribe multiple times
239
- for (let i = 0; i < 10; i++) {
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
- // Advance time to check if disposal is scheduled correctly
245
- await vi.advanceTimersByTimeAsync(unusedCacheTime)
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 after the last unsubscribe
248
- const nextStore = await registry.getOrLoad(options)
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
- it('swallows errors thrown by subscribers during notification', async () => {
255
- const registry = new StoreRegistry()
256
- const options = testStoreOptions()
257
-
258
- let errorListenerCalled = false
259
- let goodListenerCalled = false
260
-
261
- const errorListener = () => {
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 goodListener = () => {
267
- goodListenerCalled = true
268
- }
207
+ const options = testStoreOptions({
208
+ unusedCacheTime: 10, // Override with shorter time
209
+ })
269
210
 
270
- registry.subscribe(options.storeId, errorListener)
271
- registry.subscribe(options.storeId, goodListener)
211
+ const store = await registry.getOrLoadPromise(options)
272
212
 
273
- // Should not throw despite errorListener throwing
274
- const store = await registry.getOrLoad(options)
213
+ // Wait for the override time (10ms)
214
+ await sleep(10)
275
215
 
276
- // Both listeners should have been called
277
- expect(errorListenerCalled).toBe(true)
278
- expect(goodListenerCalled).toBe(true)
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 store.shutdownPromise()
220
+ await nextStore.shutdownPromise()
281
221
  })
282
222
 
283
- it('supports concurrent load and subscribe operations', async () => {
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
- let notificationCount = 0
288
- const listener = () => {
289
- notificationCount++
290
- }
228
+ const options = testStoreOptions({ unusedCacheTime: 10 })
229
+ const release = registry.retain(options)
291
230
 
292
- // Subscribe before loading starts
293
- const unsubscribe = registry.subscribe(options.storeId, listener)
231
+ const store = await registry.getOrLoadPromise(options)
294
232
 
295
- // Start loading
296
- const storePromise = registry.getOrLoad(options)
233
+ // Call with longer unusedCacheTime
234
+ await registry.getOrLoadPromise(testStoreOptions({ unusedCacheTime: 100 }))
297
235
 
298
- // Listener should be notified when loading starts
299
- expect(notificationCount).toBeGreaterThan(0)
236
+ release()
300
237
 
301
- const store = await storePromise
238
+ // After 99ms, store should still be alive (100ms unusedCacheTime used)
239
+ await sleep(99)
302
240
 
303
- // Listener should be notified when loading completes
304
- expect(notificationCount).toBe(2)
241
+ // Store should still be cached
242
+ expect(registry.getOrLoadPromise(options)).toBe(store)
305
243
 
306
- unsubscribe()
244
+ // After the full 100ms, store should be disposed
245
+ await sleep(1)
307
246
 
308
- // Clean up
309
- await store.shutdownPromise()
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('cancels disposal when a new subscription is added', async () => {
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
- const store = await registry.getOrLoad(options)
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
- // Advance time almost to disposal threshold
321
- await vi.advanceTimersByTimeAsync(unusedCacheTime - 5)
267
+ // But subsequent getOrLoadStore should throw the cached error
268
+ expect(() => registry.getOrLoadPromise(badOptions)).toThrow()
269
+ })
322
270
 
323
- // Add a new subscription before disposal triggers
324
- const unsubscribe = registry.subscribe(options.storeId, () => {})
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
- // Complete the original unusedCacheTime
327
- await vi.advanceTimersByTimeAsync(5)
276
+ const store = await registry.getOrLoadPromise(options)
328
277
 
329
- // Store should not have been disposed because we added a subscription
330
- expect(registry.getOrLoad(options)).toBe(store)
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
- // Clean up
333
- unsubscribe()
334
- await vi.advanceTimersByTimeAsync(unusedCacheTime)
284
+ // Wait for disposal to trigger
285
+ await sleep(unusedCacheTime + 50)
335
286
 
336
- // Now it should be disposed
337
- const nextStore = await registry.getOrLoad(options)
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('schedules disposal if store becomes unused during loading', async () => {
344
- vi.useFakeTimers()
345
- const registry = new StoreRegistry()
294
+ it('cancels disposal when new retain', async () => {
346
295
  const unusedCacheTime = 50
347
- const options = testStoreOptions({ unusedCacheTime })
296
+ const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
297
+ const options = testStoreOptions()
348
298
 
349
- // Start loading without any subscription
350
- const storePromise = registry.getOrLoad(options)
299
+ const store = await registry.getOrLoadPromise(options)
351
300
 
352
- // Wait for store to load (no subscribers registered)
353
- const store = await storePromise
301
+ // Wait almost to disposal threshold
302
+ await sleep(unusedCacheTime - 5)
354
303
 
355
- // Since there were no subscribers when loading completed, disposal should be scheduled
356
- await vi.advanceTimersByTimeAsync(unusedCacheTime)
304
+ // Add a new retain before disposal triggers
305
+ const release = registry.retain(options)
357
306
 
358
- // Store should be disposed
359
- const nextStore = await registry.getOrLoad(options)
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 options = testStoreOptions({ unusedCacheTime })
326
+ const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
327
+ const options = testStoreOptions()
370
328
 
371
- // Subscribe briefly to trigger getOrLoad and then unsubscribe
372
- const unsubscribe = registry.subscribe(options.storeId, () => {})
329
+ // Retain briefly to trigger getOrLoadStore and then release
330
+ const release = registry.retain(options)
373
331
 
374
- // Start loading - this will be slow due to fake timers
375
- const loadPromise = registry.getOrLoad(options)
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
- // Unsubscribe immediately, which schedules disposal
383
- unsubscribe()
340
+ // Release immediately, which schedules disposal
341
+ release()
384
342
 
385
- // Advance time to trigger disposal while still loading
386
- await vi.advanceTimersByTimeAsync(unusedCacheTime)
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 getOrLoad should start a fresh load
392
- const freshLoadPromise = registry.getOrLoad(options)
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('does not abort loading when new subscription arrives before disposal fires', async () => {
406
- vi.useFakeTimers()
407
- const registry = new StoreRegistry()
363
+ it('retain keeps store alive past unusedCacheTime', async () => {
408
364
  const unusedCacheTime = 50
409
- const options = testStoreOptions({ unusedCacheTime })
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
- // Add a new subscription - this should cancel the pending disposal
420
- const unsub2 = registry.subscribe(options.storeId, () => {})
368
+ // Load the store
369
+ const store = await registry.getOrLoadPromise(options)
421
370
 
422
- // Advance past the original unusedCacheTime
423
- await vi.advanceTimersByTimeAsync(20)
371
+ // Retain the store before disposal could fire
372
+ const release = registry.retain(options)
424
373
 
425
- // The load should complete normally (not be aborted)
426
- const store = await loadPromise
374
+ // Wait past the unusedCacheTime
375
+ await sleep(unusedCacheTime + 50)
427
376
 
428
- // And should be the same instance when retrieved again
429
- const cachedStore = registry.getOrLoad(options)
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
- unsub2()
381
+ release()
433
382
  await store.shutdownPromise()
434
383
  })
435
384
 
436
385
  it('manages multiple stores with different IDs independently', async () => {
437
- vi.useFakeTimers()
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', unusedCacheTime: 50 })
441
- const options2 = testStoreOptions({ storeId: 'store-2', unusedCacheTime: 100 })
389
+ const options1 = testStoreOptions({ storeId: 'store-1' })
390
+ const options2 = testStoreOptions({ storeId: 'store-2' })
442
391
 
443
- const store1 = await registry.getOrLoad(options1)
444
- const store2 = await registry.getOrLoad(options2)
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.getOrLoad(options1)).toBe(store1)
451
- expect(registry.getOrLoad(options2)).toBe(store2)
399
+ expect(registry.getOrLoadPromise(options1)).toBe(store1)
400
+ expect(registry.getOrLoadPromise(options2)).toBe(store2)
452
401
 
453
- // Advance time to dispose store1 only
454
- await vi.advanceTimersByTimeAsync(50)
402
+ // Wait for both stores to be disposed
403
+ await sleep(unusedCacheTime + 50)
455
404
 
456
- // store1 should be disposed, store2 should still be cached
457
- const newStore1 = await registry.getOrLoad(options1)
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
- // Subscribe to prevent disposal of newStore1
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: DEFAULT_UNUSED_CACHE_TIME * 2,
420
+ unusedCacheTime: 100,
487
421
  },
488
422
  })
489
423
 
490
424
  const options = testStoreOptions()
491
425
 
492
- const store = await registry.getOrLoad(options)
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 at library's default time
499
- await vi.advanceTimersByTimeAsync(DEFAULT_UNUSED_CACHE_TIME)
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 unusedCacheTime
502
- expect(registry.getOrLoad(options)).toBe(store)
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('allows call-site options to override default options', async () => {
508
- vi.useFakeTimers()
509
-
510
- const registry = new StoreRegistry({
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.getOrLoad(options)
447
+ const originalStore = await registry.getOrLoadPromise(options)
540
448
 
541
449
  // Verify store is cached
542
- expect(registry.getOrLoad(options)).toBe(originalStore)
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
- // The disposal callback has now executed synchronously, which means:
560
- // 1. Subscriber check passed (no subscribers)
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
- // Verify shutdown was initiated
565
- expect(shutdownStarted).toBe(true)
566
- // Shutdown is async, so it shouldn't have completed yet in the same tick
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('getOrLoad returned dying store synchronously instead of starting fresh load')
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 getOrLoad is synchronous after preload', async () => {
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 getOrLoad should return synchronously (not a Promise)
589
- const store = registry.getOrLoad(options)
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 subscribers are added', async () => {
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 options = testStoreOptions({ unusedCacheTime })
491
+ const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } })
492
+ const options = testStoreOptions()
606
493
 
607
- // Preload without subscribing
494
+ // Preload without retaining
608
495
  await registry.preload(options)
609
496
 
610
497
  // Get the store
611
- const store = registry.getOrLoad(options)
498
+ const store = registry.getOrLoadPromise(options)
612
499
  expect(store).not.toBeInstanceOf(Promise)
613
500
 
614
- // Advance time to trigger disposal
615
- await vi.advanceTimersByTimeAsync(unusedCacheTime)
501
+ // Wait for disposal to trigger
502
+ await sleep(unusedCacheTime + 50)
616
503
 
617
- // Store should be disposed since no subscribers were added
618
- const nextStore = await registry.getOrLoad(options)
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()