@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,17 +1,16 @@
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.js";
5
- import { DEFAULT_UNUSED_CACHE_TIME, StoreRegistry } from "./StoreRegistry.js";
8
+ import { StoreRegistry } from "./StoreRegistry.js";
6
9
  import { storeOptions } from "./storeOptions.js";
7
10
  describe('StoreRegistry', () => {
8
- afterEach(() => {
9
- vi.clearAllTimers();
10
- vi.useRealTimers();
11
- });
12
- it('returns a Promise when the store is loading', async () => {
11
+ it('returns a promise when the store is loading', async () => {
13
12
  const registry = new StoreRegistry();
14
- const result = registry.getOrLoad(testStoreOptions());
13
+ const result = registry.getOrLoadPromise(testStoreOptions());
15
14
  expect(result).toBeInstanceOf(Promise);
16
15
  // Clean up
17
16
  const store = await result;
@@ -19,20 +18,20 @@ describe('StoreRegistry', () => {
19
18
  });
20
19
  it('returns cached store synchronously after first load resolves', async () => {
21
20
  const registry = new StoreRegistry();
22
- const initial = registry.getOrLoad(testStoreOptions());
21
+ const initial = registry.getOrLoadPromise(testStoreOptions());
23
22
  expect(initial).toBeInstanceOf(Promise);
24
23
  const store = await initial;
25
- const cached = registry.getOrLoad(testStoreOptions());
24
+ const cached = registry.getOrLoadPromise(testStoreOptions());
26
25
  expect(cached).toBe(store);
27
26
  expect(cached).not.toBeInstanceOf(Promise);
28
27
  // Clean up
29
28
  await store.shutdownPromise();
30
29
  });
31
- it('reuses the same promise for concurrent getOrLoad calls while loading', async () => {
30
+ it('reuses the same promise for concurrent getOrLoadPromise calls while loading', async () => {
32
31
  const registry = new StoreRegistry();
33
32
  const options = testStoreOptions();
34
- const first = registry.getOrLoad(options);
35
- const second = registry.getOrLoad(options);
33
+ const first = registry.getOrLoadPromise(options);
34
+ const second = registry.getOrLoadPromise(options);
36
35
  // Both should be the same promise
37
36
  expect(first).toBe(second);
38
37
  expect(first).toBeInstanceOf(Promise);
@@ -42,102 +41,55 @@ describe('StoreRegistry', () => {
42
41
  // Clean up
43
42
  await store.shutdownPromise();
44
43
  });
45
- it('stores and rethrows the rejection on subsequent getOrLoad calls after a failure', async () => {
44
+ it('throws synchronously and rethrows on subsequent calls for sync failures', () => {
46
45
  const registry = new StoreRegistry();
47
- // Create an invalid adapter that will cause an error
48
46
  const badOptions = testStoreOptions({
49
47
  // @ts-expect-error - intentionally passing invalid adapter to trigger error
50
48
  adapter: null,
51
49
  });
52
- await expect(registry.getOrLoad(badOptions)).rejects.toThrow();
53
- // Subsequent call should throw the cached error synchronously
54
- expect(() => registry.getOrLoad(badOptions)).toThrow();
55
- });
56
- it('disposes store after unusedCacheTime expires', async () => {
57
- vi.useFakeTimers();
58
- const registry = new StoreRegistry();
59
- const unusedCacheTime = 25;
60
- const options = testStoreOptions({ unusedCacheTime });
61
- const store = await registry.getOrLoad(options);
62
- // Store should be cached
63
- expect(registry.getOrLoad(options)).toBe(store);
64
- // Advance time to trigger disposal
65
- await vi.advanceTimersByTimeAsync(unusedCacheTime);
66
- // After disposal, store should be removed
67
- // The store is removed from cache, so next getOrLoad creates a new one
68
- const nextStore = await registry.getOrLoad(options);
69
- // Should be a different store instance
70
- expect(nextStore).not.toBe(store);
71
- expect(nextStore[StoreInternalsSymbol].clientSession.debugInstanceId).toBeDefined();
72
- // Clean up the second store (first one was disposed)
73
- await nextStore.shutdownPromise();
50
+ // First call throws synchronously
51
+ expect(() => registry.getOrLoadPromise(badOptions)).toThrow();
52
+ // Subsequent call should also throw synchronously (cached error)
53
+ expect(() => registry.getOrLoadPromise(badOptions)).toThrow();
74
54
  });
75
- it('keeps the longest unusedCacheTime seen for a store when options vary across calls', async () => {
76
- vi.useFakeTimers();
55
+ it('caches and rethrows rejection on subsequent calls for async failures', async () => {
77
56
  const registry = new StoreRegistry();
78
- const options = testStoreOptions({ unusedCacheTime: 10 });
79
- const unsubscribe = registry.subscribe(options.storeId, () => { });
80
- const store = await registry.getOrLoad(options);
81
- // Call with longer unusedCacheTime
82
- await registry.getOrLoad(testStoreOptions({ unusedCacheTime: 100 }));
83
- unsubscribe();
84
- // After 99ms, store should still be alive (100ms unusedCacheTime used)
85
- await vi.advanceTimersByTimeAsync(99);
86
- // Store should still be cached
87
- expect(registry.getOrLoad(options)).toBe(store);
88
- // After the full 100ms, store should be disposed
89
- await vi.advanceTimersByTimeAsync(1);
90
- // Next getOrLoad should create a new store
91
- const nextStore = await registry.getOrLoad(options);
92
- expect(nextStore).not.toBe(store);
93
- // Clean up the second store (first one was disposed)
94
- await nextStore.shutdownPromise();
95
- });
96
- it('preload does not throw', async () => {
97
- const registry = new StoreRegistry();
98
- // Create invalid options that would cause an error
57
+ // Create an adapter that fails asynchronously (after yielding to the event loop)
58
+ const failingAdapter = () => Effect.gen(function* () {
59
+ yield* Effect.sleep(0); // Force async execution
60
+ return yield* UnknownError.make({ cause: new Error('Async failure') });
61
+ });
99
62
  const badOptions = testStoreOptions({
100
- // @ts-expect-error - intentionally passing invalid adapter to trigger error
101
- adapter: null,
63
+ adapter: failingAdapter,
102
64
  });
103
- // preload should not throw
104
- await expect(registry.preload(badOptions)).resolves.toBeUndefined();
105
- // But subsequent getOrLoad should throw the cached error
106
- expect(() => registry.getOrLoad(badOptions)).toThrow();
107
- });
108
- it('does not dispose when unusedCacheTime is Infinity', async () => {
109
- vi.useFakeTimers();
110
- const registry = new StoreRegistry();
111
- const options = testStoreOptions({ unusedCacheTime: Number.POSITIVE_INFINITY });
112
- const store = await registry.getOrLoad(options);
113
- // Store should be cached
114
- expect(registry.getOrLoad(options)).toBe(store);
115
- // Advance time by a very long duration
116
- await vi.advanceTimersByTimeAsync(1000000);
117
- // Store should still be cached (not disposed)
118
- expect(registry.getOrLoad(options)).toBe(store);
119
- // Clean up manually
120
- await store.shutdownPromise();
65
+ // First call returns a promise that rejects
66
+ await expect(registry.getOrLoadPromise(badOptions)).rejects.toThrow();
67
+ // Subsequent call should throw the cached error synchronously (RcMap caches failures)
68
+ expect(() => registry.getOrLoadPromise(badOptions)).toThrow();
121
69
  });
122
- it('throws the same error instance on multiple synchronous calls after failure', async () => {
70
+ it('throws the same error instance on multiple calls after failure', async () => {
123
71
  const registry = new StoreRegistry();
72
+ // Create an adapter that fails asynchronously
73
+ const failingAdapter = () => Effect.gen(function* () {
74
+ yield* Effect.sleep(0); // Force async execution
75
+ return yield* UnknownError.make({ cause: new Error('Async failure') });
76
+ });
124
77
  const badOptions = testStoreOptions({
125
- // @ts-expect-error - intentionally passing invalid adapter to trigger error
126
- adapter: null,
78
+ adapter: failingAdapter,
127
79
  });
128
80
  // Wait for the first failure
129
- await expect(registry.getOrLoad(badOptions)).rejects.toThrow();
130
- // Capture the errors from subsequent synchronous calls
81
+ await expect(registry.getOrLoadPromise(badOptions)).rejects.toThrow();
82
+ // Capture the errors from subsequent calls
131
83
  let error1;
132
84
  let error2;
133
85
  try {
134
- registry.getOrLoad(badOptions);
86
+ registry.getOrLoadPromise(badOptions);
135
87
  }
136
88
  catch (err) {
137
89
  error1 = err;
138
90
  }
139
91
  try {
140
- registry.getOrLoad(badOptions);
92
+ registry.getOrLoadPromise(badOptions);
141
93
  }
142
94
  catch (err) {
143
95
  error2 = err;
@@ -146,143 +98,163 @@ describe('StoreRegistry', () => {
146
98
  expect(error1).toBeDefined();
147
99
  expect(error1).toBe(error2);
148
100
  });
149
- it('notifies subscribers when store state changes', async () => {
150
- const registry = new StoreRegistry();
101
+ it('disposes store after unusedCacheTime expires', async () => {
102
+ const unusedCacheTime = 25;
103
+ const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } });
151
104
  const options = testStoreOptions();
152
- let notificationCount = 0;
153
- const listener = () => {
154
- notificationCount++;
155
- };
156
- const unsubscribe = registry.subscribe(options.storeId, listener);
157
- // Start loading the store
158
- const storePromise = registry.getOrLoad(options);
159
- // Listener should be called when store starts loading
160
- expect(notificationCount).toBe(1);
161
- const store = await storePromise;
162
- // Listener should be called when store loads successfully
163
- expect(notificationCount).toBe(2);
164
- unsubscribe();
165
- // Clean up
166
- await store.shutdownPromise();
167
- });
168
- it('handles rapid subscribe/unsubscribe cycles without errors', async () => {
169
- vi.useFakeTimers();
170
- const registry = new StoreRegistry();
171
- const unusedCacheTime = 50;
172
- const options = testStoreOptions({ unusedCacheTime });
173
- const store = await registry.getOrLoad(options);
174
- // Rapidly subscribe and unsubscribe multiple times
175
- for (let i = 0; i < 10; i++) {
176
- const unsubscribe = registry.subscribe(options.storeId, () => { });
177
- unsubscribe();
178
- }
179
- // Advance time to check if disposal is scheduled correctly
180
- await vi.advanceTimersByTimeAsync(unusedCacheTime);
181
- // Store should be disposed after the last unsubscribe
182
- const nextStore = await registry.getOrLoad(options);
105
+ const store = await registry.getOrLoadPromise(options);
106
+ // Store should be cached
107
+ expect(registry.getOrLoadPromise(options)).toBe(store);
108
+ // Wait for disposal
109
+ await sleep(unusedCacheTime + 50);
110
+ // After disposal, store should be removed
111
+ // The store is removed from cache, so next getOrLoadStore creates a new one
112
+ const nextStore = await registry.getOrLoadPromise(options);
113
+ // Should be a different store instance
183
114
  expect(nextStore).not.toBe(store);
115
+ expect(nextStore[StoreInternalsSymbol].clientSession.debugInstanceId).toBeDefined();
116
+ // Clean up the second store (first one was disposed)
184
117
  await nextStore.shutdownPromise();
185
118
  });
186
- it('swallows errors thrown by subscribers during notification', async () => {
187
- const registry = new StoreRegistry();
119
+ it('does not dispose when unusedCacheTime is Infinity', async () => {
120
+ const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime: Number.POSITIVE_INFINITY } });
188
121
  const options = testStoreOptions();
189
- let errorListenerCalled = false;
190
- let goodListenerCalled = false;
191
- const errorListener = () => {
192
- errorListenerCalled = true;
193
- throw new Error('Listener error');
194
- };
195
- const goodListener = () => {
196
- goodListenerCalled = true;
197
- };
198
- registry.subscribe(options.storeId, errorListener);
199
- registry.subscribe(options.storeId, goodListener);
200
- // Should not throw despite errorListener throwing
201
- const store = await registry.getOrLoad(options);
202
- // Both listeners should have been called
203
- expect(errorListenerCalled).toBe(true);
204
- expect(goodListenerCalled).toBe(true);
122
+ const store = await registry.getOrLoadPromise(options);
123
+ // Store should be cached
124
+ expect(registry.getOrLoadPromise(options)).toBe(store);
125
+ // Wait a reasonable duration to verify no disposal
126
+ await sleep(100);
127
+ // Store should still be cached (not disposed)
128
+ expect(registry.getOrLoadPromise(options)).toBe(store);
129
+ // Clean up manually
205
130
  await store.shutdownPromise();
206
131
  });
207
- it('supports concurrent load and subscribe operations', async () => {
208
- const registry = new StoreRegistry();
132
+ it('schedules disposal if store becomes unused during loading', async () => {
133
+ const unusedCacheTime = 50;
134
+ const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } });
209
135
  const options = testStoreOptions();
210
- let notificationCount = 0;
211
- const listener = () => {
212
- notificationCount++;
213
- };
214
- // Subscribe before loading starts
215
- const unsubscribe = registry.subscribe(options.storeId, listener);
216
- // Start loading
217
- const storePromise = registry.getOrLoad(options);
218
- // Listener should be notified when loading starts
219
- expect(notificationCount).toBeGreaterThan(0);
136
+ // Start loading without any retain
137
+ const storePromise = registry.getOrLoadPromise(options);
138
+ // Wait for store to load (no retain registered)
220
139
  const store = await storePromise;
221
- // Listener should be notified when loading completes
222
- expect(notificationCount).toBe(2);
223
- unsubscribe();
224
- // Clean up
225
- await store.shutdownPromise();
140
+ // Since there were no retain when loading completed, disposal should be scheduled
141
+ await sleep(unusedCacheTime + 50);
142
+ // Store should be disposed
143
+ const nextStore = await registry.getOrLoadPromise(options);
144
+ expect(nextStore).not.toBe(store);
145
+ await nextStore.shutdownPromise();
226
146
  });
227
- it('cancels disposal when a new subscription is added', async () => {
228
- vi.useFakeTimers();
147
+ // This test is skipped because Effect doesn't yet support different `idleTimeToLive` values for each resource in `RcMap`
148
+ // See https://github.com/livestorejs/livestore/issues/917
149
+ it.skip('allows call-site options to override default options', async () => {
150
+ const registry = new StoreRegistry({
151
+ defaultOptions: {
152
+ unusedCacheTime: 1000, // Default is long
153
+ },
154
+ });
155
+ const options = testStoreOptions({
156
+ unusedCacheTime: 10, // Override with shorter time
157
+ });
158
+ const store = await registry.getOrLoadPromise(options);
159
+ // Wait for the override time (10ms)
160
+ await sleep(10);
161
+ // Should be disposed according to the override time, not default
162
+ const nextStore = await registry.getOrLoadPromise(options);
163
+ expect(nextStore).not.toBe(store);
164
+ await nextStore.shutdownPromise();
165
+ });
166
+ // This test is skipped because we don't yet support dynamic `unusedCacheTime` updates for cached stores.
167
+ // See https://github.com/livestorejs/livestore/issues/918
168
+ it.skip('keeps the longest unusedCacheTime seen for a store when options vary across calls', async () => {
229
169
  const registry = new StoreRegistry();
230
- const unusedCacheTime = 50;
231
- const options = testStoreOptions({ unusedCacheTime });
232
- const store = await registry.getOrLoad(options);
233
- // Advance time almost to disposal threshold
234
- await vi.advanceTimersByTimeAsync(unusedCacheTime - 5);
235
- // Add a new subscription before disposal triggers
236
- const unsubscribe = registry.subscribe(options.storeId, () => { });
237
- // Complete the original unusedCacheTime
238
- await vi.advanceTimersByTimeAsync(5);
239
- // Store should not have been disposed because we added a subscription
240
- expect(registry.getOrLoad(options)).toBe(store);
241
- // Clean up
242
- unsubscribe();
243
- await vi.advanceTimersByTimeAsync(unusedCacheTime);
244
- // Now it should be disposed
245
- const nextStore = await registry.getOrLoad(options);
170
+ const options = testStoreOptions({ unusedCacheTime: 10 });
171
+ const release = registry.retain(options);
172
+ const store = await registry.getOrLoadPromise(options);
173
+ // Call with longer unusedCacheTime
174
+ await registry.getOrLoadPromise(testStoreOptions({ unusedCacheTime: 100 }));
175
+ release();
176
+ // After 99ms, store should still be alive (100ms unusedCacheTime used)
177
+ await sleep(99);
178
+ // Store should still be cached
179
+ expect(registry.getOrLoadPromise(options)).toBe(store);
180
+ // After the full 100ms, store should be disposed
181
+ await sleep(1);
182
+ // Next getOrLoadStore should create a new store
183
+ const nextStore = await registry.getOrLoadPromise(options);
246
184
  expect(nextStore).not.toBe(store);
185
+ // Clean up the second store (first one was disposed)
247
186
  await nextStore.shutdownPromise();
248
187
  });
249
- it('schedules disposal if store becomes unused during loading', async () => {
250
- vi.useFakeTimers();
188
+ it('preload does not throw', async () => {
251
189
  const registry = new StoreRegistry();
190
+ // Create invalid options that would cause an error
191
+ const badOptions = testStoreOptions({
192
+ // @ts-expect-error - intentionally passing invalid adapter to trigger error
193
+ adapter: null,
194
+ });
195
+ // preload should not throw
196
+ await expect(registry.preload(badOptions)).resolves.toBeUndefined();
197
+ // But subsequent getOrLoadStore should throw the cached error
198
+ expect(() => registry.getOrLoadPromise(badOptions)).toThrow();
199
+ });
200
+ it('handles rapid retain/release cycles without errors', async () => {
252
201
  const unusedCacheTime = 50;
253
- const options = testStoreOptions({ unusedCacheTime });
254
- // Start loading without any subscription
255
- const storePromise = registry.getOrLoad(options);
256
- // Wait for store to load (no subscribers registered)
257
- const store = await storePromise;
258
- // Since there were no subscribers when loading completed, disposal should be scheduled
259
- await vi.advanceTimersByTimeAsync(unusedCacheTime);
260
- // Store should be disposed
261
- const nextStore = await registry.getOrLoad(options);
202
+ const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } });
203
+ const options = testStoreOptions();
204
+ const store = await registry.getOrLoadPromise(options);
205
+ // Rapidly retain and release multiple times
206
+ for (let i = 0; i < 10; i++) {
207
+ const release = registry.retain(options);
208
+ release();
209
+ }
210
+ // Wait for disposal to trigger
211
+ await sleep(unusedCacheTime + 50);
212
+ // Store should be disposed after the last release
213
+ const nextStore = await registry.getOrLoadPromise(options);
214
+ expect(nextStore).not.toBe(store);
215
+ await nextStore.shutdownPromise();
216
+ });
217
+ it('cancels disposal when new retain', async () => {
218
+ const unusedCacheTime = 50;
219
+ const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } });
220
+ const options = testStoreOptions();
221
+ const store = await registry.getOrLoadPromise(options);
222
+ // Wait almost to disposal threshold
223
+ await sleep(unusedCacheTime - 5);
224
+ // Add a new retain before disposal triggers
225
+ const release = registry.retain(options);
226
+ // Complete the original unusedCacheTime
227
+ await sleep(5);
228
+ // Store should not have been disposed because we added a retain
229
+ expect(registry.getOrLoadPromise(options)).toBe(store);
230
+ // Clean up
231
+ release();
232
+ await sleep(unusedCacheTime + 50);
233
+ // Now it should be disposed
234
+ const nextStore = await registry.getOrLoadPromise(options);
262
235
  expect(nextStore).not.toBe(store);
263
236
  await nextStore.shutdownPromise();
264
237
  });
265
238
  it('aborts loading when disposal fires while store is still loading', async () => {
266
- vi.useFakeTimers();
267
- const registry = new StoreRegistry();
268
239
  const unusedCacheTime = 10;
269
- const options = testStoreOptions({ unusedCacheTime });
270
- // Subscribe briefly to trigger getOrLoad and then unsubscribe
271
- const unsubscribe = registry.subscribe(options.storeId, () => { });
272
- // Start loading - this will be slow due to fake timers
273
- const loadPromise = registry.getOrLoad(options);
240
+ const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } });
241
+ const options = testStoreOptions();
242
+ // Retain briefly to trigger getOrLoadStore and then release
243
+ const release = registry.retain(options);
244
+ // Start loading
245
+ const loadPromise = registry.getOrLoadPromise(options);
274
246
  // Attach a catch handler to prevent unhandled rejection when the load is aborted
275
247
  const abortedPromise = loadPromise.catch(() => {
276
248
  // Expected: load was aborted by disposal
277
249
  });
278
- // Unsubscribe immediately, which schedules disposal
279
- unsubscribe();
280
- // Advance time to trigger disposal while still loading
281
- await vi.advanceTimersByTimeAsync(unusedCacheTime);
250
+ // Release immediately, which schedules disposal
251
+ release();
252
+ // Wait for disposal to trigger
253
+ await sleep(unusedCacheTime + 50);
282
254
  // Wait for the abort to complete
283
255
  await abortedPromise;
284
- // After abort, a new getOrLoad should start a fresh load
285
- const freshLoadPromise = registry.getOrLoad(options);
256
+ // After abort, a new getOrLoadStore should start a fresh load
257
+ const freshLoadPromise = registry.getOrLoadPromise(options);
286
258
  // This should be a new promise (not the aborted one)
287
259
  expect(freshLoadPromise).toBeInstanceOf(Promise);
288
260
  expect(freshLoadPromise).not.toBe(loadPromise);
@@ -291,144 +263,90 @@ describe('StoreRegistry', () => {
291
263
  expect(store).toBeDefined();
292
264
  await store.shutdownPromise();
293
265
  });
294
- it('does not abort loading when new subscription arrives before disposal fires', async () => {
295
- vi.useFakeTimers();
296
- const registry = new StoreRegistry();
266
+ it('retain keeps store alive past unusedCacheTime', async () => {
297
267
  const unusedCacheTime = 50;
298
- const options = testStoreOptions({ unusedCacheTime });
299
- // Start loading and immediately unsubscribe to schedule disposal
300
- const unsub1 = registry.subscribe(options.storeId, () => { });
301
- const loadPromise = registry.getOrLoad(options);
302
- unsub1();
303
- // Advance time partially (before disposal fires)
304
- await vi.advanceTimersByTimeAsync(unusedCacheTime - 10);
305
- // Add a new subscription - this should cancel the pending disposal
306
- const unsub2 = registry.subscribe(options.storeId, () => { });
307
- // Advance past the original unusedCacheTime
308
- await vi.advanceTimersByTimeAsync(20);
309
- // The load should complete normally (not be aborted)
310
- const store = await loadPromise;
311
- // And should be the same instance when retrieved again
312
- const cachedStore = registry.getOrLoad(options);
268
+ const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } });
269
+ const options = testStoreOptions();
270
+ // Load the store
271
+ const store = await registry.getOrLoadPromise(options);
272
+ // Retain the store before disposal could fire
273
+ const release = registry.retain(options);
274
+ // Wait past the unusedCacheTime
275
+ await sleep(unusedCacheTime + 50);
276
+ // Store should still be cached because retain keeps it alive
277
+ const cachedStore = registry.getOrLoadPromise(options);
313
278
  expect(cachedStore).toBe(store);
314
- unsub2();
279
+ release();
315
280
  await store.shutdownPromise();
316
281
  });
317
282
  it('manages multiple stores with different IDs independently', async () => {
318
- vi.useFakeTimers();
319
- const registry = new StoreRegistry();
320
- const options1 = testStoreOptions({ storeId: 'store-1', unusedCacheTime: 50 });
321
- const options2 = testStoreOptions({ storeId: 'store-2', unusedCacheTime: 100 });
322
- const store1 = await registry.getOrLoad(options1);
323
- const store2 = await registry.getOrLoad(options2);
283
+ const unusedCacheTime = 50;
284
+ const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } });
285
+ const options1 = testStoreOptions({ storeId: 'store-1' });
286
+ const options2 = testStoreOptions({ storeId: 'store-2' });
287
+ const store1 = await registry.getOrLoadPromise(options1);
288
+ const store2 = await registry.getOrLoadPromise(options2);
324
289
  // Should be different store instances
325
290
  expect(store1).not.toBe(store2);
326
291
  // Both should be cached independently
327
- expect(registry.getOrLoad(options1)).toBe(store1);
328
- expect(registry.getOrLoad(options2)).toBe(store2);
329
- // Advance time to dispose store1 only
330
- await vi.advanceTimersByTimeAsync(50);
331
- // store1 should be disposed, store2 should still be cached
332
- const newStore1 = await registry.getOrLoad(options1);
292
+ expect(registry.getOrLoadPromise(options1)).toBe(store1);
293
+ expect(registry.getOrLoadPromise(options2)).toBe(store2);
294
+ // Wait for both stores to be disposed
295
+ await sleep(unusedCacheTime + 50);
296
+ // Both stores should be disposed, so next getOrLoadStore creates new ones
297
+ const newStore1 = await registry.getOrLoadPromise(options1);
333
298
  expect(newStore1).not.toBe(store1);
334
- expect(registry.getOrLoad(options2)).toBe(store2);
335
- // Subscribe to prevent disposal of newStore1
336
- const unsub1 = registry.subscribe(options1.storeId, () => { });
337
- // Advance remaining time to dispose store2
338
- await vi.advanceTimersByTimeAsync(50);
339
- // store2 should be disposed
340
- const newStore2 = await registry.getOrLoad(options2);
299
+ const newStore2 = await registry.getOrLoadPromise(options2);
341
300
  expect(newStore2).not.toBe(store2);
342
- // Subscribe to prevent disposal of newStore2
343
- const unsub2 = registry.subscribe(options2.storeId, () => { });
344
301
  // Clean up
345
- unsub1();
346
- unsub2();
347
302
  await newStore1.shutdownPromise();
348
303
  await newStore2.shutdownPromise();
349
304
  });
350
305
  it('applies default options from constructor', async () => {
351
- vi.useFakeTimers();
352
306
  const registry = new StoreRegistry({
353
307
  defaultOptions: {
354
- unusedCacheTime: DEFAULT_UNUSED_CACHE_TIME * 2,
308
+ unusedCacheTime: 100,
355
309
  },
356
310
  });
357
311
  const options = testStoreOptions();
358
- const store = await registry.getOrLoad(options);
312
+ const store = await registry.getOrLoadPromise(options);
359
313
  // Verify the store loads successfully
360
314
  expect(store).toBeDefined();
361
315
  expect(store[StoreInternalsSymbol].clientSession.debugInstanceId).toBeDefined();
362
- // Verify configured default unusedCacheTime is applied by checking disposal doesn't happen at library's default time
363
- await vi.advanceTimersByTimeAsync(DEFAULT_UNUSED_CACHE_TIME);
364
- // Store should still be cached after default unusedCacheTime
365
- expect(registry.getOrLoad(options)).toBe(store);
316
+ // Verify configured default unusedCacheTime is applied by checking disposal doesn't happen before it
317
+ await sleep(50);
318
+ // Store should still be cached after 50ms (default is 100ms)
319
+ expect(registry.getOrLoadPromise(options)).toBe(store);
366
320
  await store.shutdownPromise();
367
321
  });
368
- it('allows call-site options to override default options', async () => {
369
- vi.useFakeTimers();
370
- const registry = new StoreRegistry({
371
- defaultOptions: {
372
- unusedCacheTime: 1000, // Default is long
373
- },
374
- });
375
- const options = testStoreOptions({
376
- unusedCacheTime: 10, // Override with shorter time
377
- });
378
- const store = await registry.getOrLoad(options);
379
- // Advance by the override time (10ms)
380
- await vi.advanceTimersByTimeAsync(10);
381
- // Should be disposed according to the override time, not default
382
- const nextStore = await registry.getOrLoad(options);
383
- expect(nextStore).not.toBe(store);
384
- await nextStore.shutdownPromise();
385
- });
386
- it('prevents subscriptions to stores that are shutting down', async () => {
387
- vi.useFakeTimers();
388
- const registry = new StoreRegistry();
389
- const unusedCacheTime = 10;
390
- const options = testStoreOptions({ unusedCacheTime });
322
+ it('prevents getOrLoadStore from returning a dying store', async () => {
323
+ const unusedCacheTime = 25;
324
+ const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } });
325
+ const options = testStoreOptions();
391
326
  // Load the store and wait for it to be ready
392
- const originalStore = await registry.getOrLoad(options);
327
+ const originalStore = await registry.getOrLoadPromise(options);
393
328
  // Verify store is cached
394
- expect(registry.getOrLoad(options)).toBe(originalStore);
395
- // Spy on shutdownPromise to detect when shutdown starts
396
- let shutdownStarted = false;
397
- let shutdownCompleted = false;
398
- const originalShutdownPromise = originalStore.shutdownPromise.bind(originalStore);
399
- originalStore.shutdownPromise = () => {
400
- shutdownStarted = true;
401
- return originalShutdownPromise().finally(() => {
402
- shutdownCompleted = true;
403
- });
404
- };
405
- // Use vi.advanceTimersToNextTimer to advance ONLY to the disposal timer firing,
406
- // then immediately (before microtasks resolve) try to get the store
407
- vi.advanceTimersToNextTimer();
408
- // The disposal callback has now executed synchronously, which means:
409
- // 1. Subscriber check passed (no subscribers)
410
- // 2. shutdown() was called (but it's async, hasn't resolved yet)
411
- // 3. Cache entry SHOULD have been removed
412
- // Verify shutdown was initiated
413
- expect(shutdownStarted).toBe(true);
414
- // Shutdown is async, so it shouldn't have completed yet in the same tick
415
- expect(shutdownCompleted).toBe(false);
416
- const storeOrPromise = registry.getOrLoad(options);
329
+ expect(registry.getOrLoadPromise(options)).toBe(originalStore);
330
+ // Wait for disposal to trigger
331
+ await sleep(unusedCacheTime + 50);
332
+ // After disposal, the cache should be cleared
333
+ // Calling getOrLoadStore should start a fresh load (return Promise)
334
+ const storeOrPromise = registry.getOrLoadPromise(options);
417
335
  if (!(storeOrPromise instanceof Promise)) {
418
- expect.fail('getOrLoad returned dying store synchronously instead of starting fresh load');
336
+ expect.fail('getOrLoadStore returned dying store synchronously instead of starting fresh load');
419
337
  }
420
338
  const freshStore = await storeOrPromise;
421
339
  // A fresh load was triggered because cache was cleared
422
340
  expect(freshStore).not.toBe(originalStore);
423
341
  await freshStore.shutdownPromise();
424
342
  });
425
- it('warms the cache so subsequent getOrLoad is synchronous after preload', async () => {
343
+ it('warms the cache so subsequent getOrLoadStore is synchronous after preload', async () => {
426
344
  const registry = new StoreRegistry();
427
345
  const options = testStoreOptions();
428
346
  // Preload the store
429
347
  await registry.preload(options);
430
- // Subsequent getOrLoad should return synchronously (not a Promise)
431
- const store = registry.getOrLoad(options);
348
+ // Subsequent getOrLoadStore should return synchronously (not a Promise)
349
+ const store = registry.getOrLoadPromise(options);
432
350
  expect(store).not.toBeInstanceOf(Promise);
433
351
  // TypeScript doesn't narrow the type, so we need to assert
434
352
  if (store instanceof Promise) {
@@ -437,20 +355,19 @@ describe('StoreRegistry', () => {
437
355
  // Clean up
438
356
  await store.shutdownPromise();
439
357
  });
440
- it('schedules disposal after preload if no subscribers are added', async () => {
441
- vi.useFakeTimers();
442
- const registry = new StoreRegistry();
358
+ it('schedules disposal after preload if no retainers are added', async () => {
443
359
  const unusedCacheTime = 50;
444
- const options = testStoreOptions({ unusedCacheTime });
445
- // Preload without subscribing
360
+ const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime } });
361
+ const options = testStoreOptions();
362
+ // Preload without retaining
446
363
  await registry.preload(options);
447
364
  // Get the store
448
- const store = registry.getOrLoad(options);
365
+ const store = registry.getOrLoadPromise(options);
449
366
  expect(store).not.toBeInstanceOf(Promise);
450
- // Advance time to trigger disposal
451
- await vi.advanceTimersByTimeAsync(unusedCacheTime);
452
- // Store should be disposed since no subscribers were added
453
- const nextStore = await registry.getOrLoad(options);
367
+ // Wait for disposal to trigger
368
+ await sleep(unusedCacheTime + 50);
369
+ // Store should be disposed since no retainers were added
370
+ const nextStore = await registry.getOrLoadPromise(options);
454
371
  expect(nextStore).not.toBe(store);
455
372
  await nextStore.shutdownPromise();
456
373
  });