@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,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 {
|
|
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 {
|
|
8
|
+
import { StoreRegistry } from "./StoreRegistry.js";
|
|
6
9
|
import { storeOptions } from "./storeOptions.js";
|
|
7
10
|
describe('StoreRegistry', () => {
|
|
8
|
-
|
|
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.
|
|
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.
|
|
21
|
+
const initial = registry.getOrLoadPromise(testStoreOptions());
|
|
23
22
|
expect(initial).toBeInstanceOf(Promise);
|
|
24
23
|
const store = await initial;
|
|
25
|
-
const cached = registry.
|
|
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
|
|
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.
|
|
35
|
-
const second = registry.
|
|
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('
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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('
|
|
76
|
-
vi.useFakeTimers();
|
|
55
|
+
it('caches and rethrows rejection on subsequent calls for async failures', async () => {
|
|
77
56
|
const registry = new StoreRegistry();
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
101
|
-
adapter: null,
|
|
63
|
+
adapter: failingAdapter,
|
|
102
64
|
});
|
|
103
|
-
//
|
|
104
|
-
await expect(registry.
|
|
105
|
-
//
|
|
106
|
-
expect(() => registry.
|
|
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
|
|
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
|
-
|
|
126
|
-
adapter: null,
|
|
78
|
+
adapter: failingAdapter,
|
|
127
79
|
});
|
|
128
80
|
// Wait for the first failure
|
|
129
|
-
await expect(registry.
|
|
130
|
-
// Capture the errors from subsequent
|
|
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.
|
|
86
|
+
registry.getOrLoadPromise(badOptions);
|
|
135
87
|
}
|
|
136
88
|
catch (err) {
|
|
137
89
|
error1 = err;
|
|
138
90
|
}
|
|
139
91
|
try {
|
|
140
|
-
registry.
|
|
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('
|
|
150
|
-
const
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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('
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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('
|
|
208
|
-
const
|
|
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
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
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
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
|
231
|
-
const
|
|
232
|
-
const store = await registry.
|
|
233
|
-
//
|
|
234
|
-
await
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
await
|
|
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('
|
|
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
|
|
254
|
-
|
|
255
|
-
const
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
//
|
|
279
|
-
|
|
280
|
-
//
|
|
281
|
-
await
|
|
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
|
|
285
|
-
const freshLoadPromise = registry.
|
|
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('
|
|
295
|
-
vi.useFakeTimers();
|
|
296
|
-
const registry = new StoreRegistry();
|
|
266
|
+
it('retain keeps store alive past unusedCacheTime', async () => {
|
|
297
267
|
const unusedCacheTime = 50;
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
279
|
+
release();
|
|
315
280
|
await store.shutdownPromise();
|
|
316
281
|
});
|
|
317
282
|
it('manages multiple stores with different IDs independently', async () => {
|
|
318
|
-
|
|
319
|
-
const registry = new StoreRegistry();
|
|
320
|
-
const options1 = testStoreOptions({ storeId: 'store-1'
|
|
321
|
-
const options2 = testStoreOptions({ storeId: 'store-2'
|
|
322
|
-
const store1 = await registry.
|
|
323
|
-
const store2 = await registry.
|
|
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.
|
|
328
|
-
expect(registry.
|
|
329
|
-
//
|
|
330
|
-
await
|
|
331
|
-
//
|
|
332
|
-
const newStore1 = await registry.
|
|
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
|
-
|
|
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:
|
|
308
|
+
unusedCacheTime: 100,
|
|
355
309
|
},
|
|
356
310
|
});
|
|
357
311
|
const options = testStoreOptions();
|
|
358
|
-
const store = await registry.
|
|
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
|
|
363
|
-
await
|
|
364
|
-
// Store should still be cached after default
|
|
365
|
-
expect(registry.
|
|
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('
|
|
369
|
-
|
|
370
|
-
const registry = new StoreRegistry({
|
|
371
|
-
|
|
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.
|
|
327
|
+
const originalStore = await registry.getOrLoadPromise(options);
|
|
393
328
|
// Verify store is cached
|
|
394
|
-
expect(registry.
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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('
|
|
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
|
|
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
|
|
431
|
-
const store = registry.
|
|
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
|
|
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
|
|
445
|
-
|
|
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.
|
|
365
|
+
const store = registry.getOrLoadPromise(options);
|
|
449
366
|
expect(store).not.toBeInstanceOf(Promise);
|
|
450
|
-
//
|
|
451
|
-
await
|
|
452
|
-
// Store should be disposed since no
|
|
453
|
-
const nextStore = await registry.
|
|
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
|
});
|