@livestore/react 0.4.0-dev.17 → 0.4.0-dev.19

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 (32) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/LiveStoreProvider.d.ts +2 -2
  3. package/dist/LiveStoreProvider.d.ts.map +1 -1
  4. package/dist/LiveStoreProvider.js +2 -2
  5. package/dist/LiveStoreProvider.js.map +1 -1
  6. package/dist/LiveStoreProvider.test.js +1 -1
  7. package/dist/LiveStoreProvider.test.js.map +1 -1
  8. package/dist/__tests__/fixture.d.ts +6 -6
  9. package/dist/__tests__/fixture.d.ts.map +1 -1
  10. package/dist/__tests__/fixture.js.map +1 -1
  11. package/dist/experimental/multi-store/StoreRegistry.d.ts +11 -12
  12. package/dist/experimental/multi-store/StoreRegistry.d.ts.map +1 -1
  13. package/dist/experimental/multi-store/StoreRegistry.js +79 -43
  14. package/dist/experimental/multi-store/StoreRegistry.js.map +1 -1
  15. package/dist/experimental/multi-store/StoreRegistry.test.js +140 -49
  16. package/dist/experimental/multi-store/StoreRegistry.test.js.map +1 -1
  17. package/dist/experimental/multi-store/StoreRegistryContext.js +1 -1
  18. package/dist/experimental/multi-store/StoreRegistryContext.js.map +1 -1
  19. package/dist/experimental/multi-store/types.d.ts +6 -6
  20. package/dist/experimental/multi-store/types.d.ts.map +1 -1
  21. package/dist/useClientDocument.test.js +4 -1
  22. package/dist/useClientDocument.test.js.map +1 -1
  23. package/package.json +8 -8
  24. package/src/LiveStoreProvider.test.tsx +1 -1
  25. package/src/LiveStoreProvider.tsx +4 -4
  26. package/src/__snapshots__/useQuery.test.tsx.snap +12 -12
  27. package/src/__tests__/fixture.tsx +2 -2
  28. package/src/experimental/multi-store/StoreRegistry.test.ts +169 -49
  29. package/src/experimental/multi-store/StoreRegistry.ts +91 -47
  30. package/src/experimental/multi-store/StoreRegistryContext.tsx +1 -1
  31. package/src/experimental/multi-store/types.ts +6 -6
  32. package/src/useClientDocument.test.tsx +70 -70
@@ -4,19 +4,20 @@ import type { CachedStoreOptions, StoreId } from './types.ts'
4
4
 
5
5
  type StoreEntryState<TSchema extends LiveStoreSchema> =
6
6
  | { status: 'idle' }
7
- | { status: 'loading'; promise: Promise<Store<TSchema>> }
7
+ | { status: 'loading'; promise: Promise<Store<TSchema>>; abortController: AbortController }
8
8
  | { status: 'success'; store: Store<TSchema> }
9
9
  | { status: 'error'; error: unknown }
10
+ | { status: 'shutting_down'; shutdownPromise: Promise<void> }
10
11
 
11
12
  /**
12
- * Default garbage collection time for inactive stores.
13
+ * Default time to keep unused stores in cache.
13
14
  *
14
15
  * - Browser: 60 seconds (60,000ms)
15
- * - SSR: Infinity (disables GC to avoid disposing stores before server render completes)
16
+ * - SSR: Infinity (disables disposal to avoid disposing stores before server render completes)
16
17
  *
17
18
  * @internal Exported primarily for testing purposes.
18
19
  */
19
- export const DEFAULT_GC_TIME = typeof window === 'undefined' ? Number.POSITIVE_INFINITY : 60_000
20
+ export const DEFAULT_UNUSED_CACHE_TIME = typeof window === 'undefined' ? Number.POSITIVE_INFINITY : 60_000
20
21
 
21
22
  /**
22
23
  * @typeParam TSchema - The schema for this entry's store.
@@ -28,8 +29,8 @@ class StoreEntry<TSchema extends LiveStoreSchema = LiveStoreSchema> {
28
29
 
29
30
  #state: StoreEntryState<TSchema> = { status: 'idle' }
30
31
 
31
- #gcTime?: number
32
- #gcTimeout?: ReturnType<typeof setTimeout> | null
32
+ #unusedCacheTime?: number
33
+ #disposalTimeout?: ReturnType<typeof setTimeout> | null
33
34
 
34
35
  /**
35
36
  * Set of subscriber callbacks to notify on state changes.
@@ -41,38 +42,46 @@ class StoreEntry<TSchema extends LiveStoreSchema = LiveStoreSchema> {
41
42
  this.#cache = cache
42
43
  }
43
44
 
44
- #scheduleGC = (): void => {
45
- this.#cancelGC()
45
+ #scheduleDisposal = (): void => {
46
+ this.#cancelDisposal()
46
47
 
47
- const effectiveGcTime = this.#gcTime === undefined ? DEFAULT_GC_TIME : this.#gcTime
48
+ const effectiveTime = this.#unusedCacheTime === undefined ? DEFAULT_UNUSED_CACHE_TIME : this.#unusedCacheTime
48
49
 
49
- if (effectiveGcTime === Number.POSITIVE_INFINITY) return // Infinity disables GC
50
+ if (effectiveTime === Number.POSITIVE_INFINITY) return // Infinity disables disposal
50
51
 
51
- this.#gcTimeout = setTimeout(() => {
52
- this.#gcTimeout = null
52
+ this.#disposalTimeout = setTimeout(() => {
53
+ this.#disposalTimeout = null
53
54
 
54
55
  // Re-check to avoid racing with a new subscription
55
56
  if (this.#subscribers.size > 0) return
56
57
 
57
- void this.#shutdown().finally(() => {
58
- // Double-check again just in case shutdown was slow
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()
59
66
  if (this.#subscribers.size === 0) this.#cache.delete(this.#storeId)
60
67
  })
61
- }, effectiveGcTime)
68
+
69
+ this.#setShuttingDown(shutdownPromise)
70
+ }, effectiveTime)
62
71
  }
63
72
 
64
- #cancelGC = (): void => {
65
- if (!this.#gcTimeout) return
66
- clearTimeout(this.#gcTimeout)
67
- this.#gcTimeout = null
73
+ #cancelDisposal = (): void => {
74
+ if (!this.#disposalTimeout) return
75
+ clearTimeout(this.#disposalTimeout)
76
+ this.#disposalTimeout = null
68
77
  }
69
78
 
70
79
  /**
71
80
  * Transitions to the loading state.
72
81
  */
73
- #setPromise(promise: Promise<Store<TSchema>>): void {
82
+ #setLoading(promise: Promise<Store<TSchema>>, abortController: AbortController): void {
74
83
  if (this.#state.status === 'success' || this.#state.status === 'loading') return
75
- this.#state = { status: 'loading', promise }
84
+ this.#state = { status: 'loading', promise, abortController }
76
85
  this.#notify()
77
86
  }
78
87
 
@@ -92,6 +101,22 @@ class StoreEntry<TSchema extends LiveStoreSchema = LiveStoreSchema> {
92
101
  this.#notify()
93
102
  }
94
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
+
95
120
  /**
96
121
  * Notifies all subscribers of state changes.
97
122
  *
@@ -115,35 +140,44 @@ class StoreEntry<TSchema extends LiveStoreSchema = LiveStoreSchema> {
115
140
  * @returns Unsubscribe function
116
141
  */
117
142
  subscribe = (listener: () => void): Unsubscribe => {
118
- this.#cancelGC()
143
+ this.#cancelDisposal()
119
144
  this.#subscribers.add(listener)
120
145
  return () => {
121
146
  this.#subscribers.delete(listener)
122
- // If no more subscribers remain, schedule GC
123
- if (this.#subscribers.size === 0) this.#scheduleGC()
147
+ // If no more subscribers remain, schedule disposal
148
+ if (this.#subscribers.size === 0) this.#scheduleDisposal()
124
149
  }
125
150
  }
126
151
 
127
152
  /**
128
- * Initiates loading of the store if not already in progress.
153
+ * Gets the loaded store or initiates loading if not already in progress.
129
154
  *
130
155
  * @param options - Store creation options
131
- * @returns Promise that resolves to the loaded store or rejects with an error
156
+ * @returns The loaded store if available, or a Promise that resolves to the loaded store
132
157
  *
133
158
  * @remarks
134
159
  * This method handles the complete lifecycle of loading a store:
135
- * - Creates the store promise via createStorePromise
160
+ * - Returns the store directly if already loaded (synchronous)
161
+ * - Returns a Promise if loading is in progress or needs to be initiated
136
162
  * - Transitions through loading → success/error states
137
- * - Invokes onSettle callback for GC scheduling when needed
163
+ * - Schedules disposal when loading completes without active subscribers
138
164
  */
139
165
  getOrLoad = (options: CachedStoreOptions<TSchema>): Store<TSchema> | Promise<Store<TSchema>> => {
140
- if (options.gcTime !== undefined) this.#gcTime = Math.max(this.#gcTime ?? 0, options.gcTime)
166
+ if (options.unusedCacheTime !== undefined)
167
+ this.#unusedCacheTime = Math.max(this.#unusedCacheTime ?? 0, options.unusedCacheTime)
141
168
 
142
169
  if (this.#state.status === 'success') return this.#state.store
143
170
  if (this.#state.status === 'loading') return this.#state.promise
144
171
  if (this.#state.status === 'error') throw this.#state.error
145
172
 
146
- const promise = createStorePromise(options)
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 })
147
181
  .then((store) => {
148
182
  this.#setStore(store)
149
183
  return store
@@ -153,19 +187,30 @@ class StoreEntry<TSchema extends LiveStoreSchema = LiveStoreSchema> {
153
187
  throw error
154
188
  })
155
189
  .finally(() => {
156
- // The store entry may have become inactive (no subscribers) while loading the store
157
- if (this.#subscribers.size === 0) this.#scheduleGC()
190
+ // The store entry may have become unused (no subscribers) while loading the store
191
+ if (this.#subscribers.size === 0) this.#scheduleDisposal()
158
192
  })
159
193
 
160
- this.#setPromise(promise)
194
+ this.#setLoading(promise, abortController)
161
195
 
162
196
  return promise
163
197
  }
164
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
+
165
210
  #shutdown = async (): Promise<void> => {
166
211
  if (this.#state.status !== 'success') return
167
212
  await this.#state.store.shutdownPromise().catch((reason) => {
168
- console.warn(`Store ${this.#storeId} failed to shutdown cleanly during GC:`, reason)
213
+ console.warn(`Store ${this.#storeId} failed to shutdown cleanly during disposal:`, reason)
169
214
  })
170
215
  }
171
216
  }
@@ -174,7 +219,7 @@ class StoreEntry<TSchema extends LiveStoreSchema = LiveStoreSchema> {
174
219
  * In-memory map of {@link StoreEntry} instances keyed by {@link StoreId}.
175
220
  *
176
221
  * @privateRemarks
177
- * The cache is intentionally small; eviction and GC timers are coordinated by the client.
222
+ * The cache is intentionally small; eviction and disposal timers are coordinated by the client.
178
223
  *
179
224
  * @internal
180
225
  */
@@ -213,22 +258,22 @@ type DefaultStoreOptions = Partial<
213
258
  >
214
259
  > & {
215
260
  /**
216
- * The time in milliseconds that inactive stores remain in memory.
217
- * When a store becomes inactive, it will be garbage collected
261
+ * The time in milliseconds that unused stores remain in memory.
262
+ * When a store becomes unused (no subscribers), it will be disposed
218
263
  * after this duration.
219
264
  *
220
- * Stores transition to the inactive state as soon as they have no
265
+ * Stores transition to the unused state as soon as they have no
221
266
  * subscriptions registered, so when all components which use that
222
267
  * store have unmounted.
223
268
  *
224
269
  * @remarks
225
- * - If set to `infinity`, will disable garbage collection
270
+ * - If set to `Infinity`, will disable disposal
226
271
  * - The maximum allowed time is about {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#maximum_delay_value | 24 days}
227
272
  *
228
273
  * @defaultValue `60_000` (60 seconds) or `Infinity` during SSR to avoid
229
274
  * disposing stores before server render completes.
230
275
  */
231
- gcTime?: number
276
+ unusedCacheTime?: number
232
277
  }
233
278
 
234
279
  type StoreRegistryConfig = {
@@ -236,7 +281,7 @@ type StoreRegistryConfig = {
236
281
  }
237
282
 
238
283
  /**
239
- * Store Registry coordinating cache, GC, and Suspense reads.
284
+ * Store Registry coordinating store loading, caching, and subscription
240
285
  *
241
286
  * @public
242
287
  */
@@ -259,14 +304,13 @@ export class StoreRegistry {
259
304
  * Get or load a store, returning it directly if loaded or a promise if loading.
260
305
  *
261
306
  * @typeParam TSchema - The schema of the store to load
262
- * @returns The loaded store if available, or a Promise that resolves to the store if loading
307
+ * @returns The loaded store if available, or a Promise that resolves to the loaded store
263
308
  * @throws unknown loading error
264
309
  *
265
310
  * @remarks
266
- * - Designed to work with React.use() for Suspense integration.
267
- * - When the store is already loaded, returns the store instance directly (not wrapped in a Promise)
268
- * - When loading, returns a stable Promise reference that can be used with React.use()
269
- * - This prevents re-suspension on subsequent renders when the store is already loaded
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
270
314
  */
271
315
  getOrLoad = <TSchema extends LiveStoreSchema>(
272
316
  options: CachedStoreOptions<TSchema>,
@@ -285,7 +329,7 @@ export class StoreRegistry {
285
329
  *
286
330
  * @remarks
287
331
  * - We don't return the store or throw as this is a fire-and-forget operation.
288
- * - If the entry remains unused after preload resolves/rejects, it is scheduled for GC.
332
+ * - If the entry remains unused after preload resolves/rejects, it is scheduled for disposal.
289
333
  */
290
334
  preload = async <TSchema extends LiveStoreSchema>(options: CachedStoreOptions<TSchema>): Promise<void> => {
291
335
  try {
@@ -17,7 +17,7 @@ export const useStoreRegistry = (override?: StoreRegistry) => {
17
17
 
18
18
  const storeRegistry = React.use(StoreRegistryContext)
19
19
 
20
- if (!storeRegistry) throw new Error('useStoreRegistry() must be used within <MultiStoreProvider>')
20
+ if (!storeRegistry) throw new Error('useStoreRegistry() must be used within <StoreRegistryProvider>')
21
21
 
22
22
  return storeRegistry
23
23
  }
@@ -36,20 +36,20 @@ export type CachedStoreOptions<
36
36
  otelOptions?: Partial<OtelOptions>
37
37
  /**
38
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.
39
+ * in memory after becoming unused. When this store becomes
40
+ * unused (no subscribers), it will be disposed after this duration.
41
41
  *
42
- * Stores transition to the inactive state as soon as they have no
42
+ * Stores transition to the unused state as soon as they have no
43
43
  * subscriptions registered, so when all components which use that
44
44
  * store have unmounted.
45
45
  *
46
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
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
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
50
  *
51
51
  * @defaultValue `60_000` (60 seconds) or `Infinity` during SSR to avoid
52
52
  * disposing stores before server render completes.
53
53
  */
54
- gcTime?: number
54
+ unusedCacheTime?: number
55
55
  }
@@ -254,81 +254,81 @@ Vitest.describe('useClientDocument', () => {
254
254
  )
255
255
 
256
256
  Vitest.describe('otel', () => {
257
- it.each([{ strictMode: true }, { strictMode: false }])(
258
- 'should update the data based on component key strictMode=%s',
259
- async ({ strictMode }) => {
260
- const exporter = new InMemorySpanExporter()
261
-
262
- const provider = new BasicTracerProvider({
263
- spanProcessors: [new SimpleSpanProcessor(exporter)],
257
+ it.each([
258
+ { strictMode: true },
259
+ { strictMode: false },
260
+ ])('should update the data based on component key strictMode=%s', async ({ strictMode }) => {
261
+ const exporter = new InMemorySpanExporter()
262
+
263
+ const provider = new BasicTracerProvider({
264
+ spanProcessors: [new SimpleSpanProcessor(exporter)],
265
+ })
266
+
267
+ const otelTracer = provider.getTracer(`testing-${strictMode ? 'strict' : 'non-strict'}`)
268
+
269
+ const span = otelTracer.startSpan('test-root')
270
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
271
+
272
+ await Effect.gen(function* () {
273
+ const { wrapper, store, renderCount } = yield* makeTodoMvcReact({
274
+ otelContext,
275
+ otelTracer,
276
+ strictMode,
264
277
  })
265
278
 
266
- const otelTracer = provider.getTracer(`testing-${strictMode ? 'strict' : 'non-strict'}`)
267
-
268
- const span = otelTracer.startSpan('test-root')
269
- const otelContext = otel.trace.setSpan(otel.context.active(), span)
279
+ const { result, rerender, unmount } = ReactTesting.renderHook(
280
+ (userId: string) => {
281
+ renderCount.inc()
270
282
 
271
- await Effect.gen(function* () {
272
- const { wrapper, store, renderCount } = yield* makeTodoMvcReact({
273
- otelContext,
274
- otelTracer,
275
- strictMode,
276
- })
283
+ const [state, setState, id] = store.useClientDocument(tables.userInfo, userId)
284
+ return { state, setState, id }
285
+ },
286
+ { wrapper, initialProps: 'u1' },
287
+ )
277
288
 
278
- const { result, rerender, unmount } = ReactTesting.renderHook(
279
- (userId: string) => {
280
- renderCount.inc()
289
+ expect(result.current.id).toBe('u1')
290
+ expect(result.current.state.username).toBe('')
291
+ expect(renderCount.val).toBe(1)
292
+
293
+ // For u2 we'll make sure that the row already exists,
294
+ // so the lazy `insert` will be skipped
295
+ ReactTesting.act(() => store.commit(events.UserInfoSet({ username: 'username_u2' }, 'u2')))
296
+
297
+ rerender('u2')
298
+
299
+ expect(result.current.id).toBe('u2')
300
+ expect(result.current.state.username).toBe('username_u2')
301
+ expect(renderCount.val).toBe(2)
302
+
303
+ unmount()
304
+ span.end()
305
+ }).pipe(Effect.scoped, Effect.tapCauseLogPretty, Effect.runPromise)
306
+
307
+ await provider.forceFlush()
308
+
309
+ const mapAttributes = (attributes: otel.Attributes) => {
310
+ return ReadonlyRecord.map(attributes, (val, key) => {
311
+ if (key === 'code.stacktrace') {
312
+ return '<STACKTRACE>'
313
+ } else if (key === 'firstStackInfo') {
314
+ const stackInfo = JSON.parse(val as string) as LiveStore.StackInfo
315
+ // stackInfo.frames.shift() // Removes `renderHook.wrapper` from the stack
316
+ stackInfo.frames.forEach((_) => {
317
+ if (_.name.includes('renderHook.wrapper')) {
318
+ _.name = 'renderHook.wrapper'
319
+ }
320
+ _.filePath = '__REPLACED_FOR_SNAPSHOT__'
321
+ })
322
+ return JSON.stringify(stackInfo)
323
+ }
324
+ return val
325
+ })
326
+ }
281
327
 
282
- const [state, setState, id] = store.useClientDocument(tables.userInfo, userId)
283
- return { state, setState, id }
284
- },
285
- { wrapper, initialProps: 'u1' },
286
- )
328
+ expect(getSimplifiedRootSpan(exporter, 'createStore', mapAttributes)).toMatchSnapshot()
329
+ expect(getAllSimplifiedRootSpans(exporter, 'LiveStore:commit', mapAttributes)).toMatchSnapshot()
287
330
 
288
- expect(result.current.id).toBe('u1')
289
- expect(result.current.state.username).toBe('')
290
- expect(renderCount.val).toBe(1)
291
-
292
- // For u2 we'll make sure that the row already exists,
293
- // so the lazy `insert` will be skipped
294
- ReactTesting.act(() => store.commit(events.UserInfoSet({ username: 'username_u2' }, 'u2')))
295
-
296
- rerender('u2')
297
-
298
- expect(result.current.id).toBe('u2')
299
- expect(result.current.state.username).toBe('username_u2')
300
- expect(renderCount.val).toBe(2)
301
-
302
- unmount()
303
- span.end()
304
- }).pipe(Effect.scoped, Effect.tapCauseLogPretty, Effect.runPromise)
305
-
306
- await provider.forceFlush()
307
-
308
- const mapAttributes = (attributes: otel.Attributes) => {
309
- return ReadonlyRecord.map(attributes, (val, key) => {
310
- if (key === 'code.stacktrace') {
311
- return '<STACKTRACE>'
312
- } else if (key === 'firstStackInfo') {
313
- const stackInfo = JSON.parse(val as string) as LiveStore.StackInfo
314
- // stackInfo.frames.shift() // Removes `renderHook.wrapper` from the stack
315
- stackInfo.frames.forEach((_) => {
316
- if (_.name.includes('renderHook.wrapper')) {
317
- _.name = 'renderHook.wrapper'
318
- }
319
- _.filePath = '__REPLACED_FOR_SNAPSHOT__'
320
- })
321
- return JSON.stringify(stackInfo)
322
- }
323
- return val
324
- })
325
- }
326
-
327
- expect(getSimplifiedRootSpan(exporter, 'createStore', mapAttributes)).toMatchSnapshot()
328
- expect(getAllSimplifiedRootSpans(exporter, 'LiveStore:commit', mapAttributes)).toMatchSnapshot()
329
-
330
- await provider.shutdown()
331
- },
332
- )
331
+ await provider.shutdown()
332
+ })
333
333
  })
334
334
  })