@livestore/react 0.4.0-dev.20 → 0.4.0-dev.22
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/StoreRegistryContext.d.ts +56 -0
- package/dist/StoreRegistryContext.d.ts.map +1 -0
- package/dist/StoreRegistryContext.js +61 -0
- package/dist/StoreRegistryContext.js.map +1 -0
- package/dist/__tests__/fixture.d.ts.map +1 -1
- package/dist/__tests__/fixture.js +1 -6
- package/dist/__tests__/fixture.js.map +1 -1
- package/dist/experimental/components/LiveList.d.ts +4 -2
- package/dist/experimental/components/LiveList.d.ts.map +1 -1
- package/dist/experimental/components/LiveList.js +6 -5
- package/dist/experimental/components/LiveList.js.map +1 -1
- package/dist/experimental/mod.d.ts +0 -1
- package/dist/experimental/mod.d.ts.map +1 -1
- package/dist/experimental/mod.js +0 -1
- package/dist/experimental/mod.js.map +1 -1
- package/dist/mod.d.ts +4 -3
- package/dist/mod.d.ts.map +1 -1
- package/dist/mod.js +3 -2
- package/dist/mod.js.map +1 -1
- package/dist/useClientDocument.d.ts +33 -0
- package/dist/useClientDocument.d.ts.map +1 -1
- package/dist/useClientDocument.js +1 -4
- package/dist/useClientDocument.js.map +1 -1
- package/dist/useQuery.d.ts +1 -1
- package/dist/useQuery.d.ts.map +1 -1
- package/dist/useQuery.js +2 -5
- package/dist/useQuery.js.map +1 -1
- package/dist/useStore.d.ts +62 -7
- package/dist/useStore.d.ts.map +1 -1
- package/dist/useStore.js +73 -15
- package/dist/useStore.js.map +1 -1
- package/dist/useStore.test.d.ts.map +1 -0
- package/dist/useStore.test.js +196 -0
- package/dist/useStore.test.js.map +1 -0
- package/package.json +7 -7
- package/src/StoreRegistryContext.tsx +69 -0
- package/src/__tests__/fixture.tsx +1 -13
- package/src/experimental/components/LiveList.tsx +13 -4
- package/src/experimental/mod.ts +0 -1
- package/src/mod.ts +4 -3
- package/src/useClientDocument.ts +36 -5
- package/src/useQuery.ts +2 -6
- package/src/useStore.test.tsx +271 -0
- package/src/useStore.ts +102 -23
- package/dist/LiveStoreContext.d.ts +0 -13
- package/dist/LiveStoreContext.d.ts.map +0 -1
- package/dist/LiveStoreContext.js +0 -3
- package/dist/LiveStoreContext.js.map +0 -1
- package/dist/LiveStoreProvider.d.ts +0 -66
- package/dist/LiveStoreProvider.d.ts.map +0 -1
- package/dist/LiveStoreProvider.js +0 -232
- package/dist/LiveStoreProvider.js.map +0 -1
- package/dist/LiveStoreProvider.test.d.ts +0 -2
- package/dist/LiveStoreProvider.test.d.ts.map +0 -1
- package/dist/LiveStoreProvider.test.js +0 -117
- package/dist/LiveStoreProvider.test.js.map +0 -1
- package/dist/experimental/multi-store/StoreRegistry.d.ts +0 -61
- package/dist/experimental/multi-store/StoreRegistry.d.ts.map +0 -1
- package/dist/experimental/multi-store/StoreRegistry.js +0 -275
- package/dist/experimental/multi-store/StoreRegistry.js.map +0 -1
- package/dist/experimental/multi-store/StoreRegistry.test.d.ts +0 -2
- package/dist/experimental/multi-store/StoreRegistry.test.d.ts.map +0 -1
- package/dist/experimental/multi-store/StoreRegistry.test.js +0 -464
- package/dist/experimental/multi-store/StoreRegistry.test.js.map +0 -1
- package/dist/experimental/multi-store/StoreRegistryContext.d.ts +0 -10
- package/dist/experimental/multi-store/StoreRegistryContext.d.ts.map +0 -1
- package/dist/experimental/multi-store/StoreRegistryContext.js +0 -15
- package/dist/experimental/multi-store/StoreRegistryContext.js.map +0 -1
- package/dist/experimental/multi-store/mod.d.ts +0 -6
- package/dist/experimental/multi-store/mod.d.ts.map +0 -1
- package/dist/experimental/multi-store/mod.js +0 -6
- package/dist/experimental/multi-store/mod.js.map +0 -1
- package/dist/experimental/multi-store/storeOptions.d.ts +0 -4
- package/dist/experimental/multi-store/storeOptions.d.ts.map +0 -1
- package/dist/experimental/multi-store/storeOptions.js +0 -4
- package/dist/experimental/multi-store/storeOptions.js.map +0 -1
- package/dist/experimental/multi-store/types.d.ts +0 -44
- package/dist/experimental/multi-store/types.d.ts.map +0 -1
- package/dist/experimental/multi-store/types.js +0 -2
- package/dist/experimental/multi-store/types.js.map +0 -1
- package/dist/experimental/multi-store/useStore.d.ts +0 -11
- package/dist/experimental/multi-store/useStore.d.ts.map +0 -1
- package/dist/experimental/multi-store/useStore.js +0 -21
- package/dist/experimental/multi-store/useStore.js.map +0 -1
- package/dist/experimental/multi-store/useStore.test.d.ts.map +0 -1
- package/dist/experimental/multi-store/useStore.test.js +0 -144
- package/dist/experimental/multi-store/useStore.test.js.map +0 -1
- package/src/LiveStoreContext.ts +0 -14
- package/src/LiveStoreProvider.test.tsx +0 -248
- package/src/LiveStoreProvider.tsx +0 -421
- package/src/experimental/multi-store/StoreRegistry.test.ts +0 -631
- package/src/experimental/multi-store/StoreRegistry.ts +0 -347
- package/src/experimental/multi-store/StoreRegistryContext.tsx +0 -23
- package/src/experimental/multi-store/mod.ts +0 -5
- package/src/experimental/multi-store/storeOptions.ts +0 -8
- package/src/experimental/multi-store/types.ts +0 -55
- package/src/experimental/multi-store/useStore.test.tsx +0 -197
- package/src/experimental/multi-store/useStore.ts +0 -34
- /package/dist/{experimental/multi-store/useStore.test.d.ts → useStore.test.d.ts} +0 -0
|
@@ -1,347 +0,0 @@
|
|
|
1
|
-
import type { LiveStoreSchema } from '@livestore/common/schema'
|
|
2
|
-
import { createStorePromise, type Store, type Unsubscribe } from '@livestore/livestore'
|
|
3
|
-
import type { CachedStoreOptions, StoreId } from './types.ts'
|
|
4
|
-
|
|
5
|
-
type StoreEntryState<TSchema extends LiveStoreSchema> =
|
|
6
|
-
| { status: 'idle' }
|
|
7
|
-
| { status: 'loading'; promise: Promise<Store<TSchema>>; abortController: AbortController }
|
|
8
|
-
| { status: 'success'; store: Store<TSchema> }
|
|
9
|
-
| { status: 'error'; error: unknown }
|
|
10
|
-
| { status: 'shutting_down'; shutdownPromise: Promise<void> }
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Default time to keep unused stores in cache.
|
|
14
|
-
*
|
|
15
|
-
* - Browser: 60 seconds (60,000ms)
|
|
16
|
-
* - SSR: Infinity (disables disposal to avoid disposing stores before server render completes)
|
|
17
|
-
*
|
|
18
|
-
* @internal Exported primarily for testing purposes.
|
|
19
|
-
*/
|
|
20
|
-
export const DEFAULT_UNUSED_CACHE_TIME = typeof window === 'undefined' ? Number.POSITIVE_INFINITY : 60_000
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* @typeParam TSchema - The schema for this entry's store.
|
|
24
|
-
* @internal
|
|
25
|
-
*/
|
|
26
|
-
class StoreEntry<TSchema extends LiveStoreSchema = LiveStoreSchema> {
|
|
27
|
-
readonly #storeId: StoreId
|
|
28
|
-
readonly #cache: StoreCache
|
|
29
|
-
|
|
30
|
-
#state: StoreEntryState<TSchema> = { status: 'idle' }
|
|
31
|
-
|
|
32
|
-
#unusedCacheTime?: number
|
|
33
|
-
#disposalTimeout?: ReturnType<typeof setTimeout> | null
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Set of subscriber callbacks to notify on state changes.
|
|
37
|
-
*/
|
|
38
|
-
readonly #subscribers = new Set<() => void>()
|
|
39
|
-
|
|
40
|
-
constructor(storeId: StoreId, cache: StoreCache) {
|
|
41
|
-
this.#storeId = storeId
|
|
42
|
-
this.#cache = cache
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
#scheduleDisposal = (): void => {
|
|
46
|
-
this.#cancelDisposal()
|
|
47
|
-
|
|
48
|
-
const effectiveTime = this.#unusedCacheTime === undefined ? DEFAULT_UNUSED_CACHE_TIME : this.#unusedCacheTime
|
|
49
|
-
|
|
50
|
-
if (effectiveTime === Number.POSITIVE_INFINITY) return // Infinity disables disposal
|
|
51
|
-
|
|
52
|
-
this.#disposalTimeout = setTimeout(() => {
|
|
53
|
-
this.#disposalTimeout = null
|
|
54
|
-
|
|
55
|
-
// Re-check to avoid racing with a new subscription
|
|
56
|
-
if (this.#subscribers.size > 0) return
|
|
57
|
-
|
|
58
|
-
// Abort any in-progress loading to release resources early
|
|
59
|
-
this.#abortLoading()
|
|
60
|
-
|
|
61
|
-
// Transition to shutting_down state BEFORE starting async shutdown.
|
|
62
|
-
// This prevents new subscribers from receiving a store that's about to be disposed.
|
|
63
|
-
const shutdownPromise = this.#shutdown().finally(() => {
|
|
64
|
-
// Reset to idle so fresh loads can proceed, then remove from cache if still inactive
|
|
65
|
-
this.#setIdle()
|
|
66
|
-
if (this.#subscribers.size === 0) this.#cache.delete(this.#storeId)
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
this.#setShuttingDown(shutdownPromise)
|
|
70
|
-
}, effectiveTime)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
#cancelDisposal = (): void => {
|
|
74
|
-
if (!this.#disposalTimeout) return
|
|
75
|
-
clearTimeout(this.#disposalTimeout)
|
|
76
|
-
this.#disposalTimeout = null
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Transitions to the loading state.
|
|
81
|
-
*/
|
|
82
|
-
#setLoading(promise: Promise<Store<TSchema>>, abortController: AbortController): void {
|
|
83
|
-
if (this.#state.status === 'success' || this.#state.status === 'loading') return
|
|
84
|
-
this.#state = { status: 'loading', promise, abortController }
|
|
85
|
-
this.#notify()
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Transitions to the success state.
|
|
90
|
-
*/
|
|
91
|
-
#setStore = (store: Store<TSchema>): void => {
|
|
92
|
-
this.#state = { status: 'success', store }
|
|
93
|
-
this.#notify()
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Transitions to the error state.
|
|
98
|
-
*/
|
|
99
|
-
#setError = (error: unknown): void => {
|
|
100
|
-
this.#state = { status: 'error', error }
|
|
101
|
-
this.#notify()
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Transitions to the shutting_down state.
|
|
106
|
-
*/
|
|
107
|
-
#setShuttingDown = (shutdownPromise: Promise<void>): void => {
|
|
108
|
-
this.#state = { status: 'shutting_down', shutdownPromise }
|
|
109
|
-
this.#notify()
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Transitions to the idle state.
|
|
114
|
-
*/
|
|
115
|
-
#setIdle = (): void => {
|
|
116
|
-
this.#state = { status: 'idle' }
|
|
117
|
-
// No notify needed - getOrLoad will handle the fresh load
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Notifies all subscribers of state changes.
|
|
122
|
-
*
|
|
123
|
-
* @remarks
|
|
124
|
-
* This should be called after any meaningful state change.
|
|
125
|
-
*/
|
|
126
|
-
#notify = (): void => {
|
|
127
|
-
for (const sub of this.#subscribers) {
|
|
128
|
-
try {
|
|
129
|
-
sub()
|
|
130
|
-
} catch {
|
|
131
|
-
// Swallow to protect other listeners
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Subscribes to this entry's updates.
|
|
138
|
-
*
|
|
139
|
-
* @param listener - Callback invoked when the entry changes
|
|
140
|
-
* @returns Unsubscribe function
|
|
141
|
-
*/
|
|
142
|
-
subscribe = (listener: () => void): Unsubscribe => {
|
|
143
|
-
this.#cancelDisposal()
|
|
144
|
-
this.#subscribers.add(listener)
|
|
145
|
-
return () => {
|
|
146
|
-
this.#subscribers.delete(listener)
|
|
147
|
-
// If no more subscribers remain, schedule disposal
|
|
148
|
-
if (this.#subscribers.size === 0) this.#scheduleDisposal()
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Gets the loaded store or initiates loading if not already in progress.
|
|
154
|
-
*
|
|
155
|
-
* @param options - Store creation options
|
|
156
|
-
* @returns The loaded store if available, or a Promise that resolves to the loaded store
|
|
157
|
-
*
|
|
158
|
-
* @remarks
|
|
159
|
-
* This method handles the complete lifecycle of loading a store:
|
|
160
|
-
* - Returns the store directly if already loaded (synchronous)
|
|
161
|
-
* - Returns a Promise if loading is in progress or needs to be initiated
|
|
162
|
-
* - Transitions through loading → success/error states
|
|
163
|
-
* - Schedules disposal when loading completes without active subscribers
|
|
164
|
-
*/
|
|
165
|
-
getOrLoad = (options: CachedStoreOptions<TSchema>): Store<TSchema> | Promise<Store<TSchema>> => {
|
|
166
|
-
if (options.unusedCacheTime !== undefined)
|
|
167
|
-
this.#unusedCacheTime = Math.max(this.#unusedCacheTime ?? 0, options.unusedCacheTime)
|
|
168
|
-
|
|
169
|
-
if (this.#state.status === 'success') return this.#state.store
|
|
170
|
-
if (this.#state.status === 'loading') return this.#state.promise
|
|
171
|
-
if (this.#state.status === 'error') throw this.#state.error
|
|
172
|
-
|
|
173
|
-
// Wait for shutdown to complete, then recursively call to load a fresh store
|
|
174
|
-
if (this.#state.status === 'shutting_down') {
|
|
175
|
-
return this.#state.shutdownPromise.then(() => this.getOrLoad(options))
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const abortController = new AbortController()
|
|
179
|
-
|
|
180
|
-
const promise = createStorePromise({ ...options, signal: abortController.signal })
|
|
181
|
-
.then((store) => {
|
|
182
|
-
this.#setStore(store)
|
|
183
|
-
return store
|
|
184
|
-
})
|
|
185
|
-
.catch((error) => {
|
|
186
|
-
this.#setError(error)
|
|
187
|
-
throw error
|
|
188
|
-
})
|
|
189
|
-
.finally(() => {
|
|
190
|
-
// The store entry may have become unused (no subscribers) while loading the store
|
|
191
|
-
if (this.#subscribers.size === 0) this.#scheduleDisposal()
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
this.#setLoading(promise, abortController)
|
|
195
|
-
|
|
196
|
-
return promise
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Aborts an in-progress store load.
|
|
201
|
-
*
|
|
202
|
-
* This signals the createStorePromise to cancel, releasing resources like
|
|
203
|
-
* worker threads, SQLite connections, and network requests.
|
|
204
|
-
*/
|
|
205
|
-
#abortLoading = (): void => {
|
|
206
|
-
if (this.#state.status !== 'loading') return
|
|
207
|
-
this.#state.abortController.abort()
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
#shutdown = async (): Promise<void> => {
|
|
211
|
-
if (this.#state.status !== 'success') return
|
|
212
|
-
await this.#state.store.shutdownPromise().catch((reason) => {
|
|
213
|
-
console.warn(`Store ${this.#storeId} failed to shutdown cleanly during disposal:`, reason)
|
|
214
|
-
})
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* In-memory map of {@link StoreEntry} instances keyed by {@link StoreId}.
|
|
220
|
-
*
|
|
221
|
-
* @privateRemarks
|
|
222
|
-
* The cache is intentionally small; eviction and disposal timers are coordinated by the client.
|
|
223
|
-
*
|
|
224
|
-
* @internal
|
|
225
|
-
*/
|
|
226
|
-
class StoreCache {
|
|
227
|
-
readonly #entries = new Map<StoreId, StoreEntry>()
|
|
228
|
-
|
|
229
|
-
get = <TSchema extends LiveStoreSchema>(storeId: StoreId): StoreEntry<TSchema> | undefined => {
|
|
230
|
-
return this.#entries.get(storeId) as StoreEntry<TSchema> | undefined
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
ensure = <TSchema extends LiveStoreSchema>(storeId: StoreId): StoreEntry<TSchema> => {
|
|
234
|
-
let entry = this.#entries.get(storeId) as StoreEntry<TSchema> | undefined
|
|
235
|
-
|
|
236
|
-
if (!entry) {
|
|
237
|
-
entry = new StoreEntry<TSchema>(storeId, this)
|
|
238
|
-
this.#entries.set(storeId, entry as unknown as StoreEntry)
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
return entry
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Removes an entry from the cache.
|
|
246
|
-
*
|
|
247
|
-
* @param storeId - The ID of the store to remove
|
|
248
|
-
*/
|
|
249
|
-
delete = (storeId: StoreId): void => {
|
|
250
|
-
this.#entries.delete(storeId)
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
type DefaultStoreOptions = Partial<
|
|
255
|
-
Pick<
|
|
256
|
-
CachedStoreOptions<any>,
|
|
257
|
-
'batchUpdates' | 'disableDevtools' | 'confirmUnsavedChanges' | 'syncPayload' | 'debug' | 'otelOptions'
|
|
258
|
-
>
|
|
259
|
-
> & {
|
|
260
|
-
/**
|
|
261
|
-
* The time in milliseconds that unused stores remain in memory.
|
|
262
|
-
* When a store becomes unused (no subscribers), it will be disposed
|
|
263
|
-
* after this duration.
|
|
264
|
-
*
|
|
265
|
-
* Stores transition to the unused state as soon as they have no
|
|
266
|
-
* subscriptions registered, so when all components which use that
|
|
267
|
-
* store have unmounted.
|
|
268
|
-
*
|
|
269
|
-
* @remarks
|
|
270
|
-
* - If set to `Infinity`, will disable disposal
|
|
271
|
-
* - The maximum allowed time is about {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#maximum_delay_value | 24 days}
|
|
272
|
-
*
|
|
273
|
-
* @defaultValue `60_000` (60 seconds) or `Infinity` during SSR to avoid
|
|
274
|
-
* disposing stores before server render completes.
|
|
275
|
-
*/
|
|
276
|
-
unusedCacheTime?: number
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
type StoreRegistryConfig = {
|
|
280
|
-
defaultOptions?: DefaultStoreOptions
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Store Registry coordinating store loading, caching, and subscription
|
|
285
|
-
*
|
|
286
|
-
* @public
|
|
287
|
-
*/
|
|
288
|
-
export class StoreRegistry {
|
|
289
|
-
readonly #cache = new StoreCache()
|
|
290
|
-
readonly #defaultOptions: DefaultStoreOptions
|
|
291
|
-
|
|
292
|
-
constructor({ defaultOptions = {} }: StoreRegistryConfig = {}) {
|
|
293
|
-
this.#defaultOptions = defaultOptions
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
#applyDefaultOptions = <TSchema extends LiveStoreSchema>(
|
|
297
|
-
options: CachedStoreOptions<TSchema>,
|
|
298
|
-
): CachedStoreOptions<TSchema> => ({
|
|
299
|
-
...this.#defaultOptions,
|
|
300
|
-
...options,
|
|
301
|
-
})
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* Get or load a store, returning it directly if loaded or a promise if loading.
|
|
305
|
-
*
|
|
306
|
-
* @typeParam TSchema - The schema of the store to load
|
|
307
|
-
* @returns The loaded store if available, or a Promise that resolves to the loaded store
|
|
308
|
-
* @throws unknown loading error
|
|
309
|
-
*
|
|
310
|
-
* @remarks
|
|
311
|
-
* - Returns the store instance directly (synchronous) when already loaded
|
|
312
|
-
* - Returns a stable Promise reference when loading is in progress or needs to be initiated
|
|
313
|
-
* - Applies default options from registry config, with call-site options taking precedence
|
|
314
|
-
*/
|
|
315
|
-
getOrLoad = <TSchema extends LiveStoreSchema>(
|
|
316
|
-
options: CachedStoreOptions<TSchema>,
|
|
317
|
-
): Store<TSchema> | Promise<Store<TSchema>> => {
|
|
318
|
-
const optionsWithDefaults = this.#applyDefaultOptions(options)
|
|
319
|
-
const storeEntry = this.#cache.ensure<TSchema>(optionsWithDefaults.storeId)
|
|
320
|
-
|
|
321
|
-
return storeEntry.getOrLoad(optionsWithDefaults)
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* Warms the cache for a store without mounting a subscriber.
|
|
326
|
-
*
|
|
327
|
-
* @typeParam TSchema - The schema of the store to preload
|
|
328
|
-
* @returns A promise that resolves when the loading is complete (success or failure)
|
|
329
|
-
*
|
|
330
|
-
* @remarks
|
|
331
|
-
* - We don't return the store or throw as this is a fire-and-forget operation.
|
|
332
|
-
* - If the entry remains unused after preload resolves/rejects, it is scheduled for disposal.
|
|
333
|
-
*/
|
|
334
|
-
preload = async <TSchema extends LiveStoreSchema>(options: CachedStoreOptions<TSchema>): Promise<void> => {
|
|
335
|
-
try {
|
|
336
|
-
await this.getOrLoad(options)
|
|
337
|
-
} catch {
|
|
338
|
-
// Do nothing; preload is best-effort
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
subscribe = <TSchema extends LiveStoreSchema>(storeId: StoreId, listener: () => void): Unsubscribe => {
|
|
343
|
-
const entry = this.#cache.ensure<TSchema>(storeId)
|
|
344
|
-
|
|
345
|
-
return entry.subscribe(listener)
|
|
346
|
-
}
|
|
347
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import * as React from 'react'
|
|
2
|
-
import type { StoreRegistry } from './StoreRegistry.ts'
|
|
3
|
-
|
|
4
|
-
export const StoreRegistryContext = React.createContext<StoreRegistry | undefined>(undefined)
|
|
5
|
-
|
|
6
|
-
export type StoreRegistryProviderProps = {
|
|
7
|
-
storeRegistry: StoreRegistry
|
|
8
|
-
children: React.ReactNode
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export const StoreRegistryProvider = ({ storeRegistry, children }: StoreRegistryProviderProps): React.JSX.Element => {
|
|
12
|
-
return <StoreRegistryContext value={storeRegistry}>{children}</StoreRegistryContext>
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export const useStoreRegistry = (override?: StoreRegistry) => {
|
|
16
|
-
if (override) return override
|
|
17
|
-
|
|
18
|
-
const storeRegistry = React.use(StoreRegistryContext)
|
|
19
|
-
|
|
20
|
-
if (!storeRegistry) throw new Error('useStoreRegistry() must be used within <StoreRegistryProvider>')
|
|
21
|
-
|
|
22
|
-
return storeRegistry
|
|
23
|
-
}
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import type { LiveStoreSchema } from '@livestore/common/schema'
|
|
2
|
-
import type { CachedStoreOptions } from './types.ts'
|
|
3
|
-
|
|
4
|
-
export function storeOptions<TSchema extends LiveStoreSchema>(
|
|
5
|
-
options: CachedStoreOptions<TSchema>,
|
|
6
|
-
): CachedStoreOptions<TSchema> {
|
|
7
|
-
return options
|
|
8
|
-
}
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import type { Adapter } from '@livestore/common'
|
|
2
|
-
import type { LiveStoreSchema } from '@livestore/common/schema'
|
|
3
|
-
import type { CreateStoreOptions, OtelOptions } from '@livestore/livestore'
|
|
4
|
-
|
|
5
|
-
export type StoreId = string
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Minimum information required to create a store
|
|
9
|
-
*/
|
|
10
|
-
export type StoreDescriptor<TSchema extends LiveStoreSchema> = {
|
|
11
|
-
/**
|
|
12
|
-
* Schema describing the data structure.
|
|
13
|
-
*/
|
|
14
|
-
readonly schema: TSchema
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Adapter for persistence and synchronization.
|
|
18
|
-
*/
|
|
19
|
-
readonly adapter: Adapter
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* The ID of the store.
|
|
23
|
-
*/
|
|
24
|
-
readonly storeId: StoreId
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export type CachedStoreOptions<
|
|
28
|
-
TSchema extends LiveStoreSchema = LiveStoreSchema.Any,
|
|
29
|
-
TContext = {},
|
|
30
|
-
> = StoreDescriptor<TSchema> &
|
|
31
|
-
Pick<
|
|
32
|
-
CreateStoreOptions<TSchema, TContext>,
|
|
33
|
-
'boot' | 'batchUpdates' | 'disableDevtools' | 'confirmUnsavedChanges' | 'syncPayload' | 'debug'
|
|
34
|
-
> & {
|
|
35
|
-
signal?: AbortSignal
|
|
36
|
-
otelOptions?: Partial<OtelOptions>
|
|
37
|
-
/**
|
|
38
|
-
* The time in milliseconds that this store should remain
|
|
39
|
-
* in memory after becoming unused. When this store becomes
|
|
40
|
-
* unused (no subscribers), it will be disposed after this duration.
|
|
41
|
-
*
|
|
42
|
-
* Stores transition to the unused state as soon as they have no
|
|
43
|
-
* subscriptions registered, so when all components which use that
|
|
44
|
-
* store have unmounted.
|
|
45
|
-
*
|
|
46
|
-
* @remarks
|
|
47
|
-
* - When different `unusedCacheTime` values are used for the same store, the longest one will be used.
|
|
48
|
-
* - If set to `Infinity`, will disable automatic disposal
|
|
49
|
-
* - The maximum allowed time is about {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#maximum_delay_value | 24 days}
|
|
50
|
-
*
|
|
51
|
-
* @defaultValue `60_000` (60 seconds) or `Infinity` during SSR to avoid
|
|
52
|
-
* disposing stores before server render completes.
|
|
53
|
-
*/
|
|
54
|
-
unusedCacheTime?: number
|
|
55
|
-
}
|
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
import { makeInMemoryAdapter } from '@livestore/adapter-web'
|
|
2
|
-
import type { Store } from '@livestore/livestore'
|
|
3
|
-
import { StoreInternalsSymbol } from '@livestore/livestore'
|
|
4
|
-
import { type RenderResult, render, renderHook, waitFor } from '@testing-library/react'
|
|
5
|
-
import * as React from 'react'
|
|
6
|
-
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
-
import { schema } from '../../__tests__/fixture.tsx'
|
|
8
|
-
import { StoreRegistry } from './StoreRegistry.ts'
|
|
9
|
-
import { StoreRegistryProvider } from './StoreRegistryContext.tsx'
|
|
10
|
-
import { storeOptions } from './storeOptions.ts'
|
|
11
|
-
import type { CachedStoreOptions } from './types.ts'
|
|
12
|
-
import { useStore } from './useStore.ts'
|
|
13
|
-
|
|
14
|
-
describe('experimental useStore', () => {
|
|
15
|
-
afterEach(() => {
|
|
16
|
-
vi.clearAllTimers()
|
|
17
|
-
vi.useRealTimers()
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
it('suspends when the store is loading', async () => {
|
|
21
|
-
const registry = new StoreRegistry()
|
|
22
|
-
const options = testStoreOptions()
|
|
23
|
-
|
|
24
|
-
const view = render(
|
|
25
|
-
<StoreRegistryProvider storeRegistry={registry}>
|
|
26
|
-
<React.Suspense fallback={<div data-testid="fallback" />}>
|
|
27
|
-
<StoreConsumer options={options} />
|
|
28
|
-
</React.Suspense>
|
|
29
|
-
</StoreRegistryProvider>,
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
// Should show fallback while loading
|
|
33
|
-
expect(view.getByTestId('fallback')).toBeDefined()
|
|
34
|
-
|
|
35
|
-
// Wait for store to load and component to render
|
|
36
|
-
await waitForSuspenseResolved(view)
|
|
37
|
-
expect(view.getByTestId('ready')).toBeDefined()
|
|
38
|
-
|
|
39
|
-
cleanupWithPendingTimers(() => view.unmount())
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
it('does not re-suspend on subsequent renders when store is already loaded', async () => {
|
|
43
|
-
const registry = new StoreRegistry()
|
|
44
|
-
const options = testStoreOptions()
|
|
45
|
-
|
|
46
|
-
const Wrapper = ({ opts }: { opts: CachedStoreOptions<typeof schema> }) => (
|
|
47
|
-
<StoreRegistryProvider storeRegistry={registry}>
|
|
48
|
-
<React.Suspense fallback={<div data-testid="fallback" />}>
|
|
49
|
-
<StoreConsumer options={opts} />
|
|
50
|
-
</React.Suspense>
|
|
51
|
-
</StoreRegistryProvider>
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
const view = render(<Wrapper opts={options} />)
|
|
55
|
-
|
|
56
|
-
// Wait for initial load
|
|
57
|
-
await waitForSuspenseResolved(view)
|
|
58
|
-
expect(view.getByTestId('ready')).toBeDefined()
|
|
59
|
-
|
|
60
|
-
// Rerender with new options object (but same storeId)
|
|
61
|
-
view.rerender(<Wrapper opts={{ ...options }} />)
|
|
62
|
-
|
|
63
|
-
// Should not show fallback
|
|
64
|
-
expect(view.queryByTestId('fallback')).toBeNull()
|
|
65
|
-
expect(view.getByTestId('ready')).toBeDefined()
|
|
66
|
-
|
|
67
|
-
cleanupWithPendingTimers(() => view.unmount())
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
it('throws when store loading fails', async () => {
|
|
71
|
-
const registry = new StoreRegistry()
|
|
72
|
-
const badOptions = testStoreOptions({
|
|
73
|
-
// @ts-expect-error - intentionally passing invalid adapter to trigger error
|
|
74
|
-
adapter: null,
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
// Pre-load the store to cache the error
|
|
78
|
-
await expect(registry.getOrLoad(badOptions)).rejects.toThrow()
|
|
79
|
-
|
|
80
|
-
// Now when useStore tries to get it, it should throw synchronously
|
|
81
|
-
expect(() =>
|
|
82
|
-
renderHook(() => useStore(badOptions), {
|
|
83
|
-
wrapper: makeProvider(registry),
|
|
84
|
-
}),
|
|
85
|
-
).toThrow()
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it.each([
|
|
89
|
-
{ label: 'non-strict mode', strictMode: false },
|
|
90
|
-
{ label: 'strict mode', strictMode: true },
|
|
91
|
-
])('works in $label', async ({ strictMode }) => {
|
|
92
|
-
const registry = new StoreRegistry()
|
|
93
|
-
const options = testStoreOptions()
|
|
94
|
-
|
|
95
|
-
const { result, unmount } = renderHook(() => useStore(options), {
|
|
96
|
-
wrapper: makeProvider(registry, { suspense: true }),
|
|
97
|
-
reactStrictMode: strictMode,
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
// Wait for store to be ready
|
|
101
|
-
await waitForStoreReady(result)
|
|
102
|
-
expect(result.current[StoreInternalsSymbol].clientSession).toBeDefined()
|
|
103
|
-
|
|
104
|
-
cleanupWithPendingTimers(unmount)
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
it('handles switching between different storeId values', async () => {
|
|
108
|
-
const registry = new StoreRegistry()
|
|
109
|
-
|
|
110
|
-
const optionsA = testStoreOptions({ storeId: 'store-a' })
|
|
111
|
-
const optionsB = testStoreOptions({ storeId: 'store-b' })
|
|
112
|
-
|
|
113
|
-
const { result, rerender, unmount } = renderHook((opts) => useStore(opts), {
|
|
114
|
-
initialProps: optionsA,
|
|
115
|
-
wrapper: makeProvider(registry, { suspense: true }),
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
// Wait for first store to load
|
|
119
|
-
await waitForStoreReady(result)
|
|
120
|
-
const storeA = result.current
|
|
121
|
-
expect(storeA[StoreInternalsSymbol].clientSession).toBeDefined()
|
|
122
|
-
|
|
123
|
-
// Switch to different storeId
|
|
124
|
-
rerender(optionsB)
|
|
125
|
-
|
|
126
|
-
// Wait for second store to load and verify it's different from the first
|
|
127
|
-
await waitFor(() => {
|
|
128
|
-
expect(result.current).not.toBe(storeA)
|
|
129
|
-
expect(result.current?.[StoreInternalsSymbol].clientSession).toBeDefined()
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
const storeB = result.current
|
|
133
|
-
expect(storeB[StoreInternalsSymbol].clientSession).toBeDefined()
|
|
134
|
-
expect(storeB).not.toBe(storeA)
|
|
135
|
-
|
|
136
|
-
cleanupWithPendingTimers(unmount)
|
|
137
|
-
})
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
const StoreConsumer = ({ options }: { options: CachedStoreOptions<any> }) => {
|
|
141
|
-
useStore(options)
|
|
142
|
-
return <div data-testid="ready" />
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const makeProvider =
|
|
146
|
-
(registry: StoreRegistry, { suspense = false }: { suspense?: boolean } = {}) =>
|
|
147
|
-
({ children }: { children: React.ReactNode }) => {
|
|
148
|
-
let content = <StoreRegistryProvider storeRegistry={registry}>{children}</StoreRegistryProvider>
|
|
149
|
-
|
|
150
|
-
if (suspense) {
|
|
151
|
-
content = <React.Suspense fallback={null}>{content}</React.Suspense>
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
return content
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const testStoreOptions = (overrides: Partial<CachedStoreOptions<typeof schema>> = {}) =>
|
|
158
|
-
storeOptions({
|
|
159
|
-
storeId: 'test-store',
|
|
160
|
-
schema,
|
|
161
|
-
adapter: makeInMemoryAdapter(),
|
|
162
|
-
...overrides,
|
|
163
|
-
})
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Cleans up after component unmount by synchronously executing any pending GC timers.
|
|
167
|
-
*
|
|
168
|
-
* When components using stores unmount, the StoreRegistry schedules garbage collection
|
|
169
|
-
* timers for inactive stores. Without this cleanup, those timers may fire during
|
|
170
|
-
* subsequent tests, causing cross-test pollution and flaky failures.
|
|
171
|
-
*
|
|
172
|
-
* This helper switches to fake timers, executes only the already-pending timers
|
|
173
|
-
* (allowing stores to shut down cleanly), then restores real timers for the next test.
|
|
174
|
-
*/
|
|
175
|
-
const cleanupWithPendingTimers = (cleanup: () => void): void => {
|
|
176
|
-
vi.useFakeTimers()
|
|
177
|
-
cleanup()
|
|
178
|
-
vi.runOnlyPendingTimers()
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Waits for React Suspense fallback to resolve and the actual content to render.
|
|
183
|
-
*/
|
|
184
|
-
const waitForSuspenseResolved = async (view: RenderResult): Promise<void> => {
|
|
185
|
-
await waitFor(() => expect(view.queryByTestId('fallback')).toBeNull())
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Waits for a store to be fully loaded and ready to use.
|
|
190
|
-
* The store is considered ready when it has a defined clientSession.
|
|
191
|
-
*/
|
|
192
|
-
const waitForStoreReady = async (result: { current: Store<any> }): Promise<void> => {
|
|
193
|
-
await waitFor(() => {
|
|
194
|
-
expect(result.current).not.toBeNull()
|
|
195
|
-
expect(result.current[StoreInternalsSymbol].clientSession).toBeDefined()
|
|
196
|
-
})
|
|
197
|
-
}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import type { LiveStoreSchema } from '@livestore/common/schema'
|
|
2
|
-
import type { Store } from '@livestore/livestore'
|
|
3
|
-
import * as React from 'react'
|
|
4
|
-
import type { ReactApi } from '../../LiveStoreContext.ts'
|
|
5
|
-
import { withReactApi } from '../../useStore.ts'
|
|
6
|
-
import { useStoreRegistry } from './StoreRegistryContext.tsx'
|
|
7
|
-
import type { CachedStoreOptions } from './types.ts'
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Suspense + Error Boundary friendly hook.
|
|
11
|
-
* - Returns data or throws (Promise|Error).
|
|
12
|
-
* - No loading or error states are returned.
|
|
13
|
-
*/
|
|
14
|
-
export const useStore = <TSchema extends LiveStoreSchema>(
|
|
15
|
-
options: CachedStoreOptions<TSchema>,
|
|
16
|
-
): Store<TSchema> & ReactApi => {
|
|
17
|
-
const storeRegistry = useStoreRegistry()
|
|
18
|
-
|
|
19
|
-
const subscribe = React.useCallback(
|
|
20
|
-
(onChange: () => void) => storeRegistry.subscribe(options.storeId, onChange),
|
|
21
|
-
[storeRegistry, options.storeId],
|
|
22
|
-
)
|
|
23
|
-
const getSnapshot = React.useCallback(() => {
|
|
24
|
-
const storeOrPromise = storeRegistry.getOrLoad(options)
|
|
25
|
-
|
|
26
|
-
if (storeOrPromise instanceof Promise) throw storeOrPromise
|
|
27
|
-
|
|
28
|
-
return storeOrPromise
|
|
29
|
-
}, [storeRegistry, options])
|
|
30
|
-
|
|
31
|
-
const loadedStore = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
32
|
-
|
|
33
|
-
return withReactApi(loadedStore)
|
|
34
|
-
}
|
|
File without changes
|