@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.
Files changed (100) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/StoreRegistryContext.d.ts +56 -0
  3. package/dist/StoreRegistryContext.d.ts.map +1 -0
  4. package/dist/StoreRegistryContext.js +61 -0
  5. package/dist/StoreRegistryContext.js.map +1 -0
  6. package/dist/__tests__/fixture.d.ts.map +1 -1
  7. package/dist/__tests__/fixture.js +1 -6
  8. package/dist/__tests__/fixture.js.map +1 -1
  9. package/dist/experimental/components/LiveList.d.ts +4 -2
  10. package/dist/experimental/components/LiveList.d.ts.map +1 -1
  11. package/dist/experimental/components/LiveList.js +6 -5
  12. package/dist/experimental/components/LiveList.js.map +1 -1
  13. package/dist/experimental/mod.d.ts +0 -1
  14. package/dist/experimental/mod.d.ts.map +1 -1
  15. package/dist/experimental/mod.js +0 -1
  16. package/dist/experimental/mod.js.map +1 -1
  17. package/dist/mod.d.ts +4 -3
  18. package/dist/mod.d.ts.map +1 -1
  19. package/dist/mod.js +3 -2
  20. package/dist/mod.js.map +1 -1
  21. package/dist/useClientDocument.d.ts +33 -0
  22. package/dist/useClientDocument.d.ts.map +1 -1
  23. package/dist/useClientDocument.js +1 -4
  24. package/dist/useClientDocument.js.map +1 -1
  25. package/dist/useQuery.d.ts +1 -1
  26. package/dist/useQuery.d.ts.map +1 -1
  27. package/dist/useQuery.js +2 -5
  28. package/dist/useQuery.js.map +1 -1
  29. package/dist/useStore.d.ts +62 -7
  30. package/dist/useStore.d.ts.map +1 -1
  31. package/dist/useStore.js +73 -15
  32. package/dist/useStore.js.map +1 -1
  33. package/dist/useStore.test.d.ts.map +1 -0
  34. package/dist/useStore.test.js +196 -0
  35. package/dist/useStore.test.js.map +1 -0
  36. package/package.json +7 -7
  37. package/src/StoreRegistryContext.tsx +69 -0
  38. package/src/__tests__/fixture.tsx +1 -13
  39. package/src/experimental/components/LiveList.tsx +13 -4
  40. package/src/experimental/mod.ts +0 -1
  41. package/src/mod.ts +4 -3
  42. package/src/useClientDocument.ts +36 -5
  43. package/src/useQuery.ts +2 -6
  44. package/src/useStore.test.tsx +271 -0
  45. package/src/useStore.ts +102 -23
  46. package/dist/LiveStoreContext.d.ts +0 -13
  47. package/dist/LiveStoreContext.d.ts.map +0 -1
  48. package/dist/LiveStoreContext.js +0 -3
  49. package/dist/LiveStoreContext.js.map +0 -1
  50. package/dist/LiveStoreProvider.d.ts +0 -66
  51. package/dist/LiveStoreProvider.d.ts.map +0 -1
  52. package/dist/LiveStoreProvider.js +0 -232
  53. package/dist/LiveStoreProvider.js.map +0 -1
  54. package/dist/LiveStoreProvider.test.d.ts +0 -2
  55. package/dist/LiveStoreProvider.test.d.ts.map +0 -1
  56. package/dist/LiveStoreProvider.test.js +0 -117
  57. package/dist/LiveStoreProvider.test.js.map +0 -1
  58. package/dist/experimental/multi-store/StoreRegistry.d.ts +0 -61
  59. package/dist/experimental/multi-store/StoreRegistry.d.ts.map +0 -1
  60. package/dist/experimental/multi-store/StoreRegistry.js +0 -275
  61. package/dist/experimental/multi-store/StoreRegistry.js.map +0 -1
  62. package/dist/experimental/multi-store/StoreRegistry.test.d.ts +0 -2
  63. package/dist/experimental/multi-store/StoreRegistry.test.d.ts.map +0 -1
  64. package/dist/experimental/multi-store/StoreRegistry.test.js +0 -464
  65. package/dist/experimental/multi-store/StoreRegistry.test.js.map +0 -1
  66. package/dist/experimental/multi-store/StoreRegistryContext.d.ts +0 -10
  67. package/dist/experimental/multi-store/StoreRegistryContext.d.ts.map +0 -1
  68. package/dist/experimental/multi-store/StoreRegistryContext.js +0 -15
  69. package/dist/experimental/multi-store/StoreRegistryContext.js.map +0 -1
  70. package/dist/experimental/multi-store/mod.d.ts +0 -6
  71. package/dist/experimental/multi-store/mod.d.ts.map +0 -1
  72. package/dist/experimental/multi-store/mod.js +0 -6
  73. package/dist/experimental/multi-store/mod.js.map +0 -1
  74. package/dist/experimental/multi-store/storeOptions.d.ts +0 -4
  75. package/dist/experimental/multi-store/storeOptions.d.ts.map +0 -1
  76. package/dist/experimental/multi-store/storeOptions.js +0 -4
  77. package/dist/experimental/multi-store/storeOptions.js.map +0 -1
  78. package/dist/experimental/multi-store/types.d.ts +0 -44
  79. package/dist/experimental/multi-store/types.d.ts.map +0 -1
  80. package/dist/experimental/multi-store/types.js +0 -2
  81. package/dist/experimental/multi-store/types.js.map +0 -1
  82. package/dist/experimental/multi-store/useStore.d.ts +0 -11
  83. package/dist/experimental/multi-store/useStore.d.ts.map +0 -1
  84. package/dist/experimental/multi-store/useStore.js +0 -21
  85. package/dist/experimental/multi-store/useStore.js.map +0 -1
  86. package/dist/experimental/multi-store/useStore.test.d.ts.map +0 -1
  87. package/dist/experimental/multi-store/useStore.test.js +0 -144
  88. package/dist/experimental/multi-store/useStore.test.js.map +0 -1
  89. package/src/LiveStoreContext.ts +0 -14
  90. package/src/LiveStoreProvider.test.tsx +0 -248
  91. package/src/LiveStoreProvider.tsx +0 -421
  92. package/src/experimental/multi-store/StoreRegistry.test.ts +0 -631
  93. package/src/experimental/multi-store/StoreRegistry.ts +0 -347
  94. package/src/experimental/multi-store/StoreRegistryContext.tsx +0 -23
  95. package/src/experimental/multi-store/mod.ts +0 -5
  96. package/src/experimental/multi-store/storeOptions.ts +0 -8
  97. package/src/experimental/multi-store/types.ts +0 -55
  98. package/src/experimental/multi-store/useStore.test.tsx +0 -197
  99. package/src/experimental/multi-store/useStore.ts +0 -34
  100. /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,5 +0,0 @@
1
- export * from './StoreRegistry.ts'
2
- export * from './StoreRegistryContext.tsx'
3
- export * from './storeOptions.ts'
4
- export * from './types.ts'
5
- export * from './useStore.ts'
@@ -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
- }