@livestore/react 0.4.0-dev.13 → 0.4.0-dev.15

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 (63) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/LiveStoreProvider.d.ts +4 -3
  3. package/dist/LiveStoreProvider.d.ts.map +1 -1
  4. package/dist/LiveStoreProvider.js +10 -3
  5. package/dist/LiveStoreProvider.js.map +1 -1
  6. package/dist/LiveStoreProvider.test.js +3 -3
  7. package/dist/LiveStoreProvider.test.js.map +1 -1
  8. package/dist/__tests__/fixture.d.ts +1 -1
  9. package/dist/__tests__/fixture.js +2 -2
  10. package/dist/__tests__/fixture.js.map +1 -1
  11. package/dist/experimental/components/LiveList.js +1 -1
  12. package/dist/experimental/mod.d.ts +1 -0
  13. package/dist/experimental/mod.d.ts.map +1 -1
  14. package/dist/experimental/mod.js +1 -0
  15. package/dist/experimental/mod.js.map +1 -1
  16. package/dist/experimental/multi-store/StoreRegistry.d.ts +62 -0
  17. package/dist/experimental/multi-store/StoreRegistry.d.ts.map +1 -0
  18. package/dist/experimental/multi-store/StoreRegistry.js +239 -0
  19. package/dist/experimental/multi-store/StoreRegistry.js.map +1 -0
  20. package/dist/experimental/multi-store/StoreRegistryContext.d.ts +10 -0
  21. package/dist/experimental/multi-store/StoreRegistryContext.d.ts.map +1 -0
  22. package/dist/experimental/multi-store/StoreRegistryContext.js +15 -0
  23. package/dist/experimental/multi-store/StoreRegistryContext.js.map +1 -0
  24. package/dist/experimental/multi-store/mod.d.ts +6 -0
  25. package/dist/experimental/multi-store/mod.d.ts.map +1 -0
  26. package/dist/experimental/multi-store/mod.js +6 -0
  27. package/dist/experimental/multi-store/mod.js.map +1 -0
  28. package/dist/experimental/multi-store/storeOptions.d.ts +4 -0
  29. package/dist/experimental/multi-store/storeOptions.d.ts.map +1 -0
  30. package/dist/experimental/multi-store/storeOptions.js +4 -0
  31. package/dist/experimental/multi-store/storeOptions.js.map +1 -0
  32. package/dist/experimental/multi-store/types.d.ts +44 -0
  33. package/dist/experimental/multi-store/types.d.ts.map +1 -0
  34. package/dist/experimental/multi-store/types.js +2 -0
  35. package/dist/experimental/multi-store/types.js.map +1 -0
  36. package/dist/experimental/multi-store/useStore.d.ts +11 -0
  37. package/dist/experimental/multi-store/useStore.d.ts.map +1 -0
  38. package/dist/experimental/multi-store/useStore.js +17 -0
  39. package/dist/experimental/multi-store/useStore.js.map +1 -0
  40. package/dist/useClientDocument.test.js +2 -2
  41. package/dist/useClientDocument.test.js.map +1 -1
  42. package/dist/useQuery.test.js +2 -2
  43. package/dist/useQuery.test.js.map +1 -1
  44. package/dist/useRcResource.test.js +1 -1
  45. package/dist/useStore.d.ts +2 -1
  46. package/dist/useStore.d.ts.map +1 -1
  47. package/dist/useStore.js.map +1 -1
  48. package/package.json +6 -6
  49. package/src/LiveStoreProvider.test.tsx +3 -3
  50. package/src/LiveStoreProvider.tsx +15 -5
  51. package/src/__tests__/fixture.tsx +2 -2
  52. package/src/experimental/components/LiveList.tsx +1 -1
  53. package/src/experimental/mod.ts +1 -0
  54. package/src/experimental/multi-store/StoreRegistry.ts +304 -0
  55. package/src/experimental/multi-store/StoreRegistryContext.tsx +23 -0
  56. package/src/experimental/multi-store/mod.ts +5 -0
  57. package/src/experimental/multi-store/storeOptions.ts +8 -0
  58. package/src/experimental/multi-store/types.ts +55 -0
  59. package/src/experimental/multi-store/useStore.ts +30 -0
  60. package/src/useClientDocument.test.tsx +3 -3
  61. package/src/useQuery.test.tsx +2 -2
  62. package/src/useRcResource.test.tsx +1 -1
  63. package/src/useStore.ts +3 -2
@@ -0,0 +1,304 @@
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>> }
8
+ | { status: 'success'; store: Store<TSchema> }
9
+ | { status: 'error'; error: unknown }
10
+
11
+ /**
12
+ * Minimal cache entry that tracks store state and subscribers.
13
+ *
14
+ * @typeParam TSchema - The schema for this entry's store.
15
+ * @internal
16
+ */
17
+ class StoreEntry<TSchema extends LiveStoreSchema = LiveStoreSchema> {
18
+ #state: StoreEntryState<TSchema> = { status: 'idle' }
19
+
20
+ /**
21
+ * Set of subscriber callbacks to notify on state changes.
22
+ */
23
+ #subscribers = new Set<() => void>()
24
+
25
+ /**
26
+ * The number of active subscribers for this entry.
27
+ */
28
+ get subscriberCount() {
29
+ return this.#subscribers.size
30
+ }
31
+
32
+ /**
33
+ * Transitions to the loading state.
34
+ */
35
+ #setPromise(promise: Promise<Store<TSchema>>): void {
36
+ if (this.#state.status === 'success' || this.#state.status === 'loading') return
37
+ this.#state = { status: 'loading', promise }
38
+ this.#notify()
39
+ }
40
+
41
+ /**
42
+ * Transitions to the success state.
43
+ */
44
+ #setStore = (store: Store<TSchema>): void => {
45
+ this.#state = { status: 'success', store }
46
+ this.#notify()
47
+ }
48
+
49
+ /**
50
+ * Transitions to the error state.
51
+ */
52
+ #setError = (error: unknown): void => {
53
+ this.#state = { status: 'error', error }
54
+ this.#notify()
55
+ }
56
+
57
+ #reset = (): void => {
58
+ this.#state = { status: 'idle' }
59
+ this.#notify()
60
+ }
61
+
62
+ /**
63
+ * Notifies all subscribers of state changes.
64
+ *
65
+ * @remarks
66
+ * This should be called after any meaningful state change.
67
+ */
68
+ #notify = (): void => {
69
+ for (const sub of this.#subscribers) {
70
+ try {
71
+ sub()
72
+ } catch {
73
+ // Swallow to protect other listeners
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Subscribes to this entry's updates.
80
+ *
81
+ * @param listener - Callback invoked when the entry changes
82
+ * @returns Unsubscribe function
83
+ */
84
+ subscribe = (listener: () => void): Unsubscribe => {
85
+ this.#subscribers.add(listener)
86
+ return () => {
87
+ this.#subscribers.delete(listener)
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Initiates loading of the store if not already in progress.
93
+ *
94
+ * @param options - Store creation options
95
+ * @returns Promise that resolves to the loaded store or rejects with an error
96
+ *
97
+ * @remarks
98
+ * This method handles the complete lifecycle of loading a store:
99
+ * - Creates the store promise via createStorePromise
100
+ * - Transitions through loading → success/error states
101
+ * - Invokes onSettle callback for GC scheduling when needed
102
+ */
103
+ getOrLoad = (options: CachedStoreOptions<TSchema>): Store<TSchema> | Promise<Store<TSchema>> => {
104
+ if (this.#state.status === 'success') return this.#state.store
105
+ if (this.#state.status === 'loading') return this.#state.promise
106
+ if (this.#state.status === 'error') throw this.#state.error
107
+
108
+ const promise = createStorePromise(options)
109
+ .then((store) => {
110
+ this.#setStore(store)
111
+ return store
112
+ })
113
+ .catch((error) => {
114
+ this.#setError(error)
115
+ throw error
116
+ })
117
+
118
+ this.#setPromise(promise)
119
+
120
+ return promise
121
+ }
122
+
123
+ shutdown = async (): Promise<void> => {
124
+ if (this.#state.status !== 'success') return
125
+ await this.#state.store.shutdownPromise()
126
+
127
+ this.#reset()
128
+ }
129
+ }
130
+
131
+ /**
132
+ * In-memory map of {@link StoreEntry} instances keyed by {@link StoreId}.
133
+ *
134
+ * @privateRemarks
135
+ * The cache is intentionally small; eviction and GC timers are coordinated by the client.
136
+ *
137
+ * @internal
138
+ */
139
+ class StoreCache {
140
+ readonly #entries = new Map<StoreId, StoreEntry>()
141
+
142
+ get = <TSchema extends LiveStoreSchema>(storeId: StoreId): StoreEntry<TSchema> | undefined => {
143
+ return this.#entries.get(storeId) as StoreEntry<TSchema> | undefined
144
+ }
145
+
146
+ ensure = <TSchema extends LiveStoreSchema>(storeId: StoreId): StoreEntry<TSchema> => {
147
+ let entry = this.#entries.get(storeId) as StoreEntry<TSchema> | undefined
148
+
149
+ if (!entry) {
150
+ entry = new StoreEntry<TSchema>()
151
+ this.#entries.set(storeId, entry as unknown as StoreEntry)
152
+ }
153
+
154
+ return entry
155
+ }
156
+
157
+ /**
158
+ * Removes an entry from the cache.
159
+ *
160
+ * @param storeId - The ID of the store to remove
161
+ *
162
+ * @remarks
163
+ * - Invokes shutdown on the store before removal.
164
+ */
165
+ remove = (storeId: StoreId): void => {
166
+ const entry = this.#entries.get(storeId)
167
+ if (!entry) return
168
+ void entry.shutdown()
169
+ this.#entries.delete(storeId)
170
+ }
171
+ }
172
+
173
+ const DEFAULT_GC_TIME = typeof window === 'undefined' ? Number.POSITIVE_INFINITY : 60_000
174
+
175
+ type DefaultStoreOptions = Partial<
176
+ Pick<
177
+ CachedStoreOptions<any>,
178
+ 'batchUpdates' | 'disableDevtools' | 'confirmUnsavedChanges' | 'syncPayload' | 'debug' | 'otelOptions'
179
+ >
180
+ > & {
181
+ /**
182
+ * The time in milliseconds that inactive stores remain in memory.
183
+ * When a store becomes inactive, it will be garbage collected
184
+ * after this duration.
185
+ *
186
+ * Stores transition to the inactive state as soon as they have no
187
+ * subscriptions registered, so when all components which use that
188
+ * store have unmounted.
189
+ *
190
+ * @remarks
191
+ * - If set to `infinity`, will disable garbage collection
192
+ * - The maximum allowed time is about {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#maximum_delay_value | 24 days}
193
+ *
194
+ * @defaultValue `60_000` (60 seconds) or `Infinity` during SSR to avoid
195
+ * disposing stores before server render completes.
196
+ */
197
+ gcTime?: number
198
+ }
199
+
200
+ type StoreRegistryConfig = {
201
+ defaultOptions?: DefaultStoreOptions
202
+ }
203
+
204
+ /**
205
+ * Store Registry coordinating cache, GC, and Suspense reads.
206
+ *
207
+ * @public
208
+ */
209
+ export class StoreRegistry {
210
+ readonly #cache = new StoreCache()
211
+ readonly #gcTimeouts = new Map<StoreId, ReturnType<typeof setTimeout>>()
212
+ readonly #defaultOptions: DefaultStoreOptions
213
+
214
+ constructor({ defaultOptions = {} }: StoreRegistryConfig = {}) {
215
+ this.#defaultOptions = defaultOptions
216
+ }
217
+
218
+ #applyDefaultOptions = <TSchema extends LiveStoreSchema>(
219
+ options: CachedStoreOptions<TSchema>,
220
+ ): CachedStoreOptions<TSchema> => ({
221
+ ...this.#defaultOptions,
222
+ ...options,
223
+ })
224
+
225
+ #scheduleGC = (id: StoreId): void => {
226
+ this.#cancelGC(id)
227
+ const timer = setTimeout(() => {
228
+ this.#gcTimeouts.delete(id)
229
+ this.#cache.remove(id)
230
+ }, DEFAULT_GC_TIME)
231
+ this.#gcTimeouts.set(id, timer)
232
+ }
233
+
234
+ #cancelGC = (id: StoreId): void => {
235
+ const t = this.#gcTimeouts.get(id)
236
+ if (t) {
237
+ clearTimeout(t)
238
+ this.#gcTimeouts.delete(id)
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Get or load a store, returning it directly if loaded or a promise if loading.
244
+ *
245
+ * @typeParam TSchema - The schema of the store to load
246
+ * @returns The loaded store if available, or a Promise that resolves to the store if loading
247
+ * @throws unknown loading error
248
+ *
249
+ * @remarks
250
+ * - Designed to work with React.use() for Suspense integration.
251
+ * - When the store is already loaded, returns the store instance directly (not wrapped in a Promise)
252
+ * - When loading, returns a stable Promise reference that can be used with React.use()
253
+ * - This prevents re-suspension on subsequent renders when the store is already loaded
254
+ */
255
+ getOrLoad = <TSchema extends LiveStoreSchema>(
256
+ options: CachedStoreOptions<TSchema>,
257
+ ): Store<TSchema> | Promise<Store<TSchema>> => {
258
+ const optionsWithDefaults = this.#applyDefaultOptions(options)
259
+ const entry = this.#cache.ensure<TSchema>(optionsWithDefaults.storeId)
260
+
261
+ const storeOrPromise = entry.getOrLoad(optionsWithDefaults)
262
+
263
+ if (storeOrPromise instanceof Promise) {
264
+ return storeOrPromise.finally(() => {
265
+ // If no subscribers remain after load settles, schedule GC
266
+ if (entry.subscriberCount === 0) this.#scheduleGC(optionsWithDefaults.storeId)
267
+ })
268
+ }
269
+
270
+ return storeOrPromise
271
+ }
272
+
273
+ /**
274
+ * Warms the cache for a store without mounting a subscriber.
275
+ *
276
+ * @typeParam TSchema - The schema of the store to preload
277
+ * @returns A promise that resolves when the loading is complete (success or failure)
278
+ *
279
+ * @remarks
280
+ * - We don't return the store or throw as this is a fire-and-forget operation.
281
+ * - If the entry remains unused after preload resolves/rejects, it is scheduled for GC.
282
+ */
283
+ preload = async <TSchema extends LiveStoreSchema>(options: CachedStoreOptions<TSchema>): Promise<void> => {
284
+ try {
285
+ await this.getOrLoad(options)
286
+ } catch {
287
+ // Do nothing; preload is best-effort
288
+ }
289
+ }
290
+
291
+ subscribe = <TSchema extends LiveStoreSchema>(storeId: StoreId, listener: () => void): Unsubscribe => {
292
+ const entry = this.#cache.ensure<TSchema>(storeId)
293
+ // Active subscriber: cancel any scheduled GC
294
+ this.#cancelGC(storeId)
295
+
296
+ const unsubscribe = entry.subscribe(listener)
297
+
298
+ return () => {
299
+ unsubscribe()
300
+ // If no more subscribers remain, schedule GC
301
+ if (entry.subscriberCount === 0) this.#scheduleGC(storeId)
302
+ }
303
+ }
304
+ }
@@ -0,0 +1,23 @@
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 <MultiStoreProvider>')
21
+
22
+ return storeRegistry
23
+ }
@@ -0,0 +1,5 @@
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'
@@ -0,0 +1,8 @@
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
+ }
@@ -0,0 +1,55 @@
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 inactive. When this store becomes
40
+ * inactive, it will be garbage collected after this duration.
41
+ *
42
+ * Stores transition to the inactive 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 `gcTime` config are used for the same store, the longest one will be used.
48
+ * - If set to `Infinity`, will disable garbage collection
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
+ gcTime?: number
55
+ }
@@ -0,0 +1,30 @@
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(() => storeRegistry.getOrLoad(options), [storeRegistry, options])
24
+
25
+ const storeOrPromise = React.useSyncExternalStore(subscribe, getSnapshot)
26
+
27
+ const loadedStore = storeOrPromise instanceof Promise ? React.use(storeOrPromise) : storeOrPromise
28
+
29
+ return withReactApi(loadedStore)
30
+ }
@@ -10,9 +10,9 @@ import * as ReactTesting from '@testing-library/react'
10
10
  import type React from 'react'
11
11
  import { beforeEach, expect, it } from 'vitest'
12
12
 
13
- import { events, makeTodoMvcReact, tables } from './__tests__/fixture.js'
14
- import type * as LiveStoreReact from './mod.js'
15
- import { __resetUseRcResourceCache } from './useRcResource.js'
13
+ import { events, makeTodoMvcReact, tables } from './__tests__/fixture.tsx'
14
+ import type * as LiveStoreReact from './mod.ts'
15
+ import { __resetUseRcResourceCache } from './useRcResource.ts'
16
16
 
17
17
  // const strictMode = process.env.REACT_STRICT_MODE !== undefined
18
18
 
@@ -10,8 +10,8 @@ import React from 'react'
10
10
  import * as ReactWindow from 'react-window'
11
11
  import { expect } from 'vitest'
12
12
 
13
- import { events, makeTodoMvcReact, tables } from './__tests__/fixture.js'
14
- import { __resetUseRcResourceCache } from './useRcResource.js'
13
+ import { events, makeTodoMvcReact, tables } from './__tests__/fixture.tsx'
14
+ import { __resetUseRcResourceCache } from './useRcResource.ts'
15
15
 
16
16
  Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
17
17
  'useQuery (strictMode=%s)',
@@ -2,7 +2,7 @@ import * as ReactTesting from '@testing-library/react'
2
2
  import * as React from 'react'
3
3
  import { beforeEach, describe, expect, it, vi } from 'vitest'
4
4
 
5
- import { __resetUseRcResourceCache, useRcResource } from './useRcResource.js'
5
+ import { __resetUseRcResourceCache, useRcResource } from './useRcResource.ts'
6
6
 
7
7
  describe.each([{ strictMode: true }, { strictMode: false }])('useRcResource (strictMode=%s)', ({ strictMode }) => {
8
8
  beforeEach(() => {
package/src/useStore.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { LiveStoreSchema } from '@livestore/common/schema'
1
2
  import type { Store } from '@livestore/livestore'
2
3
  import React from 'react'
3
4
 
@@ -6,14 +7,14 @@ import { LiveStoreContext } from './LiveStoreContext.ts'
6
7
  import { useClientDocument } from './useClientDocument.ts'
7
8
  import { useQuery } from './useQuery.ts'
8
9
 
9
- export const withReactApi = (store: Store): Store & ReactApi => {
10
+ export const withReactApi = <TSchema extends LiveStoreSchema>(store: Store<TSchema>): Store<TSchema> & ReactApi => {
10
11
  // @ts-expect-error TODO properly implement this
11
12
 
12
13
  store.useQuery = (queryable) => useQuery(queryable, { store })
13
14
  // @ts-expect-error TODO properly implement this
14
15
 
15
16
  store.useClientDocument = (table, idOrOptions, options) => useClientDocument(table, idOrOptions, options, { store })
16
- return store as Store & ReactApi
17
+ return store as Store<TSchema> & ReactApi
17
18
  }
18
19
 
19
20
  export const useStore = (options?: { store?: Store }): { store: Store & ReactApi } => {