@livestore/react 0.4.0-dev.14 → 0.4.0-dev.16

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 (34) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/LiveStoreProvider.d.ts +5 -4
  3. package/dist/LiveStoreProvider.d.ts.map +1 -1
  4. package/dist/LiveStoreProvider.js +17 -6
  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 +1 -1
  10. package/dist/experimental/components/LiveList.js +1 -1
  11. package/dist/experimental/multi-store/StoreRegistry.d.ts +4 -17
  12. package/dist/experimental/multi-store/StoreRegistry.d.ts.map +1 -1
  13. package/dist/experimental/multi-store/StoreRegistry.js +118 -165
  14. package/dist/experimental/multi-store/StoreRegistry.js.map +1 -1
  15. package/dist/experimental/multi-store/StoreRegistryContext.d.ts +1 -1
  16. package/dist/experimental/multi-store/useStore.d.ts.map +1 -1
  17. package/dist/experimental/multi-store/useStore.js +2 -6
  18. package/dist/experimental/multi-store/useStore.js.map +1 -1
  19. package/dist/useClientDocument.test.js +2 -2
  20. package/dist/useClientDocument.test.js.map +1 -1
  21. package/dist/useQuery.test.js +2 -2
  22. package/dist/useQuery.test.js.map +1 -1
  23. package/dist/useRcResource.test.js +1 -1
  24. package/package.json +6 -6
  25. package/src/LiveStoreProvider.test.tsx +3 -3
  26. package/src/LiveStoreProvider.tsx +32 -24
  27. package/src/__tests__/fixture.tsx +1 -1
  28. package/src/experimental/components/LiveList.tsx +1 -1
  29. package/src/experimental/multi-store/StoreRegistry.ts +121 -173
  30. package/src/experimental/multi-store/StoreRegistryContext.tsx +1 -1
  31. package/src/experimental/multi-store/useStore.ts +2 -11
  32. package/src/useClientDocument.test.tsx +3 -3
  33. package/src/useQuery.test.tsx +2 -2
  34. package/src/useRcResource.test.tsx +1 -1
@@ -7,9 +7,9 @@ import React from 'react'
7
7
  import { unstable_batchedUpdates as batchUpdates } from 'react-dom'
8
8
  import { describe, expect, it } from 'vitest'
9
9
 
10
- import { events, schema, tables } from './__tests__/fixture.js'
11
- import { LiveStoreProvider } from './LiveStoreProvider.js'
12
- import * as LiveStoreReact from './mod.js'
10
+ import { events, schema, tables } from './__tests__/fixture.tsx'
11
+ import { LiveStoreProvider } from './LiveStoreProvider.tsx'
12
+ import * as LiveStoreReact from './mod.ts'
13
13
 
14
14
  describe.each([true, false])('LiveStoreProvider (strictMode: %s)', (strictMode) => {
15
15
  const WithStrictMode = strictMode ? React.StrictMode : React.Fragment
@@ -1,5 +1,5 @@
1
1
  import type { Adapter, BootStatus, IntentionalShutdownCause, MigrationsReport, SyncError } from '@livestore/common'
2
- import { provideOtel, UnexpectedError } from '@livestore/common'
2
+ import { LogConfig, provideOtel, UnexpectedError } from '@livestore/common'
3
3
  import type { LiveStoreSchema } from '@livestore/common/schema'
4
4
  import type {
5
5
  CreateStoreOptions,
@@ -11,24 +11,14 @@ import type {
11
11
  import { createStore, makeShutdownDeferred, StoreInterrupted } from '@livestore/livestore'
12
12
  import { errorToString, IS_REACT_NATIVE, LS_DEV, omitUndefineds } from '@livestore/utils'
13
13
  import type { OtelTracer } from '@livestore/utils/effect'
14
- import {
15
- Cause,
16
- Deferred,
17
- Effect,
18
- Exit,
19
- identity,
20
- Logger,
21
- LogLevel,
22
- Schema,
23
- Scope,
24
- TaskTracing,
25
- } from '@livestore/utils/effect'
14
+ import { Cause, Deferred, Effect, Exit, identity, Schema, Scope, TaskTracing } from '@livestore/utils/effect'
26
15
  import type * as otel from '@opentelemetry/api'
27
16
  import React from 'react'
28
17
 
29
18
  import { LiveStoreContext } from './LiveStoreContext.ts'
30
19
 
31
- export interface LiveStoreProviderProps {
20
+ export interface LiveStoreProviderProps<TSyncPayloadSchema extends Schema.Schema<any> = typeof Schema.JsonValue>
21
+ extends LogConfig.WithLoggerOptions {
32
22
  schema: LiveStoreSchema
33
23
  /**
34
24
  * The `storeId` can be used to isolate multiple stores from each other.
@@ -77,7 +67,8 @@ export interface LiveStoreProviderProps {
77
67
  *
78
68
  * @default undefined
79
69
  */
80
- syncPayload?: Schema.JsonValue
70
+ syncPayloadSchema?: TSyncPayloadSchema
71
+ syncPayload?: Schema.Schema.Type<TSyncPayloadSchema>
81
72
  debug?: {
82
73
  instanceId?: string
83
74
  }
@@ -108,7 +99,7 @@ const defaultRenderShutdown = (cause: IntentionalShutdownCause | StoreInterrupte
108
99
  const defaultRenderLoading = (status: BootStatus) =>
109
100
  IS_REACT_NATIVE ? null : <>LiveStore is loading ({status.stage})...</>
110
101
 
111
- export const LiveStoreProvider = ({
102
+ export const LiveStoreProvider = <TSyncPayloadSchema extends Schema.Schema<any> = typeof Schema.JsonValue>({
112
103
  renderLoading = defaultRenderLoading,
113
104
  renderError = defaultRenderError,
114
105
  renderShutdown = defaultRenderShutdown,
@@ -123,8 +114,11 @@ export const LiveStoreProvider = ({
123
114
  signal,
124
115
  confirmUnsavedChanges = true,
125
116
  syncPayload,
117
+ syncPayloadSchema,
126
118
  debug,
127
- }: LiveStoreProviderProps & React.PropsWithChildren): React.ReactNode => {
119
+ logger,
120
+ logLevel,
121
+ }: LiveStoreProviderProps<TSyncPayloadSchema> & React.PropsWithChildren): React.ReactNode => {
128
122
  const storeCtx = useCreateStore({
129
123
  storeId,
130
124
  schema,
@@ -137,8 +131,11 @@ export const LiveStoreProvider = ({
137
131
  disableDevtools,
138
132
  signal,
139
133
  syncPayload,
134
+ syncPayloadSchema,
140
135
  debug,
141
136
  }),
137
+ logger,
138
+ logLevel,
142
139
  })
143
140
 
144
141
  if (storeCtx.stage === 'error') {
@@ -175,11 +172,15 @@ const useCreateStore = ({
175
172
  params,
176
173
  confirmUnsavedChanges,
177
174
  syncPayload,
175
+ syncPayloadSchema,
178
176
  debug,
179
- }: CreateStoreOptions<LiveStoreSchema> & {
180
- signal?: AbortSignal
181
- otelOptions?: Partial<OtelOptions>
182
- }) => {
177
+ logger,
178
+ logLevel,
179
+ }: CreateStoreOptions<LiveStoreSchema> &
180
+ LogConfig.WithLoggerOptions & {
181
+ signal?: AbortSignal
182
+ otelOptions?: Partial<OtelOptions>
183
+ }) => {
183
184
  const [_, rerender] = React.useState(0)
184
185
  const ctxValueRef = React.useRef<{
185
186
  value: StoreContext_ | BootStatus
@@ -211,6 +212,7 @@ const useCreateStore = ({
211
212
  params,
212
213
  confirmUnsavedChanges,
213
214
  syncPayload,
215
+ syncPayloadSchema,
214
216
  debugInstanceId,
215
217
  })
216
218
 
@@ -239,6 +241,7 @@ const useCreateStore = ({
239
241
  params: inputPropsCacheRef.current.params !== params,
240
242
  confirmUnsavedChanges: inputPropsCacheRef.current.confirmUnsavedChanges !== confirmUnsavedChanges,
241
243
  syncPayload: inputPropsCacheRef.current.syncPayload !== syncPayload,
244
+ syncPayloadSchema: inputPropsCacheRef.current.syncPayloadSchema !== syncPayloadSchema,
242
245
  debugInstanceId: inputPropsCacheRef.current.debugInstanceId !== debugInstanceId,
243
246
  }
244
247
 
@@ -253,7 +256,8 @@ const useCreateStore = ({
253
256
  inputPropChanges.context ||
254
257
  inputPropChanges.params ||
255
258
  inputPropChanges.confirmUnsavedChanges ||
256
- inputPropChanges.syncPayload
259
+ inputPropChanges.syncPayload ||
260
+ inputPropChanges.syncPayloadSchema
257
261
  ) {
258
262
  inputPropsCacheRef.current = {
259
263
  schema,
@@ -267,6 +271,7 @@ const useCreateStore = ({
267
271
  params,
268
272
  confirmUnsavedChanges,
269
273
  syncPayload,
274
+ syncPayloadSchema,
270
275
  debugInstanceId,
271
276
  }
272
277
  if (ctxValueRef.current.componentScope !== undefined && ctxValueRef.current.shutdownDeferred !== undefined) {
@@ -342,6 +347,7 @@ const useCreateStore = ({
342
347
  params,
343
348
  confirmUnsavedChanges,
344
349
  syncPayload,
350
+ syncPayloadSchema,
345
351
  }),
346
352
  onBootStatus: (status) => {
347
353
  if (ctxValueRef.current.value.stage === 'running' || ctxValueRef.current.value.stage === 'error') return
@@ -374,8 +380,7 @@ const useCreateStore = ({
374
380
  provideOtel(omitUndefineds({ parentSpanContext: otelOptions?.rootSpanContext, otelTracer: otelOptions?.tracer })),
375
381
  Effect.tapCauseLogPretty,
376
382
  Effect.annotateLogs({ thread: 'window' }),
377
- Effect.provide(Logger.prettyWithThread('window')),
378
- Logger.withMinimumLogLevel(LogLevel.Debug),
383
+ LogConfig.withLoggerConfig({ logger, logLevel }, { threadName: 'window' }),
379
384
  Effect.runCallback,
380
385
  )
381
386
 
@@ -405,8 +410,11 @@ const useCreateStore = ({
405
410
  params,
406
411
  confirmUnsavedChanges,
407
412
  syncPayload,
413
+ syncPayloadSchema,
408
414
  debugInstanceId,
409
415
  interrupt,
416
+ logger,
417
+ logLevel,
410
418
  ])
411
419
 
412
420
  return ctxValueRef.current.value
@@ -8,7 +8,7 @@ import { Effect, Schema, type Scope } from '@livestore/utils/effect'
8
8
  import type * as otel from '@opentelemetry/api'
9
9
  import React from 'react'
10
10
 
11
- import * as LiveStoreReact from '../mod.js'
11
+ import * as LiveStoreReact from '../mod.ts'
12
12
 
13
13
  export type Todo = {
14
14
  id: string
@@ -2,7 +2,7 @@ import type { LiveQueryDef } from '@livestore/livestore'
2
2
  import { computed } from '@livestore/livestore'
3
3
  import React from 'react'
4
4
 
5
- import { useQuery } from '../../useQuery.js'
5
+ import { useQuery } from '../../useQuery.ts'
6
6
 
7
7
  /*
8
8
  TODO:
@@ -1,48 +1,78 @@
1
1
  import type { LiveStoreSchema } from '@livestore/common/schema'
2
2
  import { createStorePromise, type Store, type Unsubscribe } from '@livestore/livestore'
3
- import { noop } from '@livestore/utils'
4
3
  import type { CachedStoreOptions, StoreId } from './types.ts'
5
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
+
6
11
  /**
7
- * Minimal cache entry that tracks store, error, and in-flight promise along with subscribers.
12
+ * Minimal cache entry that tracks store state and subscribers.
8
13
  *
9
14
  * @typeParam TSchema - The schema for this entry's store.
10
15
  * @internal
11
16
  */
12
17
  class StoreEntry<TSchema extends LiveStoreSchema = LiveStoreSchema> {
18
+ #state: StoreEntryState<TSchema> = { status: 'idle' }
19
+
13
20
  /**
14
- * The resolved store.
15
- *
16
- * @remarks
17
- * A value of `undefined` indicates "not loaded yet".
21
+ * Set of subscriber callbacks to notify on state changes.
18
22
  */
19
- store: Store<TSchema> | undefined = undefined
23
+ #subscribers = new Set<() => void>()
20
24
 
21
25
  /**
22
- * The most recent error encountered for this entry, if any.
26
+ * The number of active subscribers for this entry.
23
27
  */
24
- error: unknown = undefined
28
+ get subscriberCount() {
29
+ return this.#subscribers.size
30
+ }
25
31
 
26
32
  /**
27
- * The in-flight promise for loading the store, or `undefined` if not yet loading or already resolved.
33
+ * Transitions to the loading state.
28
34
  */
29
- promise: Promise<Store<TSchema>> | undefined = undefined
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
+ }
30
40
 
31
41
  /**
32
- * Set of subscriber callbacks to notify on state changes.
42
+ * Transitions to the success state.
33
43
  */
34
- #subscribers = new Set<() => void>()
44
+ #setStore = (store: Store<TSchema>): void => {
45
+ this.#state = { status: 'success', store }
46
+ this.#notify()
47
+ }
35
48
 
36
49
  /**
37
- * Monotonic counter that increments on every notify.
50
+ * Transitions to the error state.
38
51
  */
39
- version = 0
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
+ }
40
61
 
41
62
  /**
42
- * The number of active subscribers for this entry.
63
+ * Notifies all subscribers of state changes.
64
+ *
65
+ * @remarks
66
+ * This should be called after any meaningful state change.
43
67
  */
44
- get subscriberCount() {
45
- return this.#subscribers.size
68
+ #notify = (): void => {
69
+ for (const sub of this.#subscribers) {
70
+ try {
71
+ sub()
72
+ } catch {
73
+ // Swallow to protect other listeners
74
+ }
75
+ }
46
76
  }
47
77
 
48
78
  /**
@@ -59,33 +89,42 @@ class StoreEntry<TSchema extends LiveStoreSchema = LiveStoreSchema> {
59
89
  }
60
90
 
61
91
  /**
62
- * Notifies all subscribers and increments the version counter.
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
63
96
  *
64
97
  * @remarks
65
- * This should be called after any meaningful state change.
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
66
102
  */
67
- notify = (): void => {
68
- this.version++
69
- for (const sub of this.#subscribers) {
70
- try {
71
- sub()
72
- } catch {
73
- // Swallow to protect other listeners
74
- }
75
- }
76
- }
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)
77
119
 
78
- setStore = (store: Store<TSchema>): void => {
79
- this.store = store
80
- this.error = undefined
81
- this.promise = undefined
82
- this.notify()
120
+ return promise
83
121
  }
84
122
 
85
- setError = (error: unknown): void => {
86
- this.error = error
87
- this.promise = undefined
88
- this.notify()
123
+ shutdown = async (): Promise<void> => {
124
+ if (this.#state.status !== 'success') return
125
+ await this.#state.store.shutdownPromise()
126
+
127
+ this.#reset()
89
128
  }
90
129
  }
91
130
 
@@ -104,7 +143,7 @@ class StoreCache {
104
143
  return this.#entries.get(storeId) as StoreEntry<TSchema> | undefined
105
144
  }
106
145
 
107
- getOrCreate = <TSchema extends LiveStoreSchema>(storeId: StoreId): StoreEntry<TSchema> => {
146
+ ensure = <TSchema extends LiveStoreSchema>(storeId: StoreId): StoreEntry<TSchema> => {
108
147
  let entry = this.#entries.get(storeId) as StoreEntry<TSchema> | undefined
109
148
 
110
149
  if (!entry) {
@@ -116,29 +155,18 @@ class StoreCache {
116
155
  }
117
156
 
118
157
  /**
119
- * Removes an entry from the cache and notifies its subscribers.
158
+ * Removes an entry from the cache.
120
159
  *
121
160
  * @param storeId - The ID of the store to remove
161
+ *
122
162
  * @remarks
123
- * Notifying subscribers prompts consumers to re-render and re-read as needed.
163
+ * - Invokes shutdown on the store before removal.
124
164
  */
125
165
  remove = (storeId: StoreId): void => {
126
166
  const entry = this.#entries.get(storeId)
127
167
  if (!entry) return
168
+ void entry.shutdown()
128
169
  this.#entries.delete(storeId)
129
- // Notify any subscribers of the removal to force re-render;
130
- // components will resubscribe to a new entry and re-read.
131
- try {
132
- entry.notify()
133
- } catch {
134
- // Best-effort notify; swallowing to avoid crashing removal flows.
135
- }
136
- }
137
-
138
- clear = (): void => {
139
- for (const storeId of Array.from(this.#entries.keys())) {
140
- this.remove(storeId)
141
- }
142
170
  }
143
171
  }
144
172
 
@@ -187,112 +215,59 @@ export class StoreRegistry {
187
215
  this.#defaultOptions = defaultOptions
188
216
  }
189
217
 
190
- /**
191
- * Ensures a store entry exists in the cache.
192
- *
193
- * @param storeId - The ID of the store
194
- * @returns The existing or newly created store entry
195
- *
196
- * @internal
197
- */
198
- ensureStoreEntry = <TSchema extends LiveStoreSchema>(storeId: StoreId): StoreEntry<TSchema> => {
199
- return this.#cache.getOrCreate<TSchema>(storeId)
200
- }
201
-
202
- /**
203
- * Resolves a store instance for imperative code paths.
204
- *
205
- * @typeParam TSchema - Schema associated with the requested store.
206
- * @returns A promise that resolves with the ready store or rejects with the loading error.
207
- *
208
- * @remarks
209
- * - If the store is already cached, the returned promise resolves immediately with that instance.
210
- * - Concurrent callers share the same in-flight request to avoid duplicate store creation.
211
- */
212
- load = async <TSchema extends LiveStoreSchema>(options: CachedStoreOptions<TSchema>): Promise<Store<TSchema>> => {
213
- const optionsWithDefaults = this.#applyDefaultOptions(options)
214
- const entry = this.ensureStoreEntry<TSchema>(optionsWithDefaults.storeId)
215
-
216
- // If already loaded, return it
217
- if (entry.store) return entry.store
218
-
219
- // If a load is already in flight, return its promise
220
- if (entry.promise) return entry.promise
221
-
222
- // If a previous error exists, throw it
223
- if (entry.error !== undefined) throw entry.error
224
-
225
- // Load store if none is in flight
226
- entry.promise = createStorePromise(optionsWithDefaults)
227
- .then((store) => {
228
- entry.setStore(store)
229
-
230
- // If no one subscribed (e.g., initial render aborted), schedule GC.
231
- if (entry.subscriberCount === 0) this.#scheduleGC(optionsWithDefaults.storeId)
232
-
233
- return store
234
- })
235
- .catch((error) => {
236
- entry.setError(error)
237
-
238
- // Likewise, ensure unused entries are eventually collected.
239
- if (entry.subscriberCount === 0) this.#scheduleGC(optionsWithDefaults.storeId)
218
+ #applyDefaultOptions = <TSchema extends LiveStoreSchema>(
219
+ options: CachedStoreOptions<TSchema>,
220
+ ): CachedStoreOptions<TSchema> => ({
221
+ ...this.#defaultOptions,
222
+ ...options,
223
+ })
240
224
 
241
- throw error
242
- })
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
+ }
243
233
 
244
- return entry.promise
234
+ #cancelGC = (id: StoreId): void => {
235
+ const t = this.#gcTimeouts.get(id)
236
+ if (t) {
237
+ clearTimeout(t)
238
+ this.#gcTimeouts.delete(id)
239
+ }
245
240
  }
246
241
 
247
242
  /**
248
- * Reads a store, returning it directly if loaded or a promise if loading.
249
- * Designed to work with React.use() for Suspense integration.
243
+ * Get or load a store, returning it directly if loaded or a promise if loading.
250
244
  *
251
245
  * @typeParam TSchema - The schema of the store to load
252
246
  * @returns The loaded store if available, or a Promise that resolves to the store if loading
253
- * @throws unknown loading error to integrate with React Error Boundaries
247
+ * @throws unknown loading error
254
248
  *
255
249
  * @remarks
250
+ * - Designed to work with React.use() for Suspense integration.
256
251
  * - When the store is already loaded, returns the store instance directly (not wrapped in a Promise)
257
252
  * - When loading, returns a stable Promise reference that can be used with React.use()
258
253
  * - This prevents re-suspension on subsequent renders when the store is already loaded
259
- * - If the initial render that triggered the fetch never commits, we still schedule GC on settle.
260
254
  */
261
- read = <TSchema extends LiveStoreSchema>(
255
+ getOrLoad = <TSchema extends LiveStoreSchema>(
262
256
  options: CachedStoreOptions<TSchema>,
263
257
  ): Store<TSchema> | Promise<Store<TSchema>> => {
264
258
  const optionsWithDefaults = this.#applyDefaultOptions(options)
265
- const entry = this.ensureStoreEntry<TSchema>(optionsWithDefaults.storeId)
266
-
267
- // If already loaded, return it directly (not wrapped in Promise)
268
- if (entry.store) return entry.store
259
+ const entry = this.#cache.ensure<TSchema>(optionsWithDefaults.storeId)
269
260
 
270
- // If a previous error exists, throw it
271
- if (entry.error !== undefined) throw entry.error
261
+ const storeOrPromise = entry.getOrLoad(optionsWithDefaults)
272
262
 
273
- // If a load is already in flight, return the existing promise
274
- if (entry.promise) return entry.promise
275
-
276
- // Load store if none is in flight
277
- entry.promise = createStorePromise(optionsWithDefaults)
278
- .then((store) => {
279
- entry.setStore(store)
280
-
281
- // If no one subscribed (e.g., initial render aborted), schedule GC.
263
+ if (storeOrPromise instanceof Promise) {
264
+ return storeOrPromise.finally(() => {
265
+ // If no subscribers remain after load settles, schedule GC
282
266
  if (entry.subscriberCount === 0) this.#scheduleGC(optionsWithDefaults.storeId)
283
-
284
- return store
285
- })
286
- .catch((error) => {
287
- entry.setError(error)
288
-
289
- // Likewise, ensure unused entries are eventually collected.
290
- if (entry.subscriberCount === 0) this.#scheduleGC(optionsWithDefaults.storeId)
291
-
292
- throw error
293
267
  })
268
+ }
294
269
 
295
- return entry.promise
270
+ return storeOrPromise
296
271
  }
297
272
 
298
273
  /**
@@ -306,11 +281,15 @@ export class StoreRegistry {
306
281
  * - If the entry remains unused after preload resolves/rejects, it is scheduled for GC.
307
282
  */
308
283
  preload = async <TSchema extends LiveStoreSchema>(options: CachedStoreOptions<TSchema>): Promise<void> => {
309
- return this.load(options).then(noop).catch(noop)
284
+ try {
285
+ await this.getOrLoad(options)
286
+ } catch {
287
+ // Do nothing; preload is best-effort
288
+ }
310
289
  }
311
290
 
312
291
  subscribe = <TSchema extends LiveStoreSchema>(storeId: StoreId, listener: () => void): Unsubscribe => {
313
- const entry = this.ensureStoreEntry<TSchema>(storeId)
292
+ const entry = this.#cache.ensure<TSchema>(storeId)
314
293
  // Active subscriber: cancel any scheduled GC
315
294
  this.#cancelGC(storeId)
316
295
 
@@ -319,38 +298,7 @@ export class StoreRegistry {
319
298
  return () => {
320
299
  unsubscribe()
321
300
  // If no more subscribers remain, schedule GC
322
- if (entry.subscriberCount === 0) {
323
- this.#scheduleGC(storeId)
324
- }
325
- }
326
- }
327
-
328
- getVersion = <TSchema extends LiveStoreSchema>(storeId: StoreId): number => {
329
- const entry = this.ensureStoreEntry<TSchema>(storeId)
330
- return entry.version
331
- }
332
-
333
- #applyDefaultOptions = <TSchema extends LiveStoreSchema>(
334
- options: CachedStoreOptions<TSchema>,
335
- ): CachedStoreOptions<TSchema> => ({
336
- ...this.#defaultOptions,
337
- ...options,
338
- })
339
-
340
- #scheduleGC = (id: StoreId): void => {
341
- this.#cancelGC(id)
342
- const timer = setTimeout(() => {
343
- this.#gcTimeouts.delete(id)
344
- this.#cache.remove(id)
345
- }, DEFAULT_GC_TIME)
346
- this.#gcTimeouts.set(id, timer)
347
- }
348
-
349
- #cancelGC = (id: StoreId): void => {
350
- const t = this.#gcTimeouts.get(id)
351
- if (t) {
352
- clearTimeout(t)
353
- this.#gcTimeouts.delete(id)
301
+ if (entry.subscriberCount === 0) this.#scheduleGC(storeId)
354
302
  }
355
303
  }
356
304
  }
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react'
2
- import type { StoreRegistry } from './StoreRegistry.js'
2
+ import type { StoreRegistry } from './StoreRegistry.ts'
3
3
 
4
4
  export const StoreRegistryContext = React.createContext<StoreRegistry | undefined>(undefined)
5
5
 
@@ -16,23 +16,14 @@ export const useStore = <TSchema extends LiveStoreSchema>(
16
16
  ): Store<TSchema> & ReactApi => {
17
17
  const storeRegistry = useStoreRegistry()
18
18
 
19
- storeRegistry.ensureStoreEntry(options.storeId)
20
-
21
19
  const subscribe = React.useCallback(
22
20
  (onChange: () => void) => storeRegistry.subscribe(options.storeId, onChange),
23
21
  [storeRegistry, options.storeId],
24
22
  )
25
- const getSnapshot = React.useCallback(
26
- () => storeRegistry.getVersion(options.storeId),
27
- [storeRegistry, options.storeId],
28
- )
29
-
30
- React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
23
+ const getSnapshot = React.useCallback(() => storeRegistry.getOrLoad(options), [storeRegistry, options])
31
24
 
32
- const storeOrPromise = storeRegistry.read(options)
25
+ const storeOrPromise = React.useSyncExternalStore(subscribe, getSnapshot)
33
26
 
34
- // If read() returns a Promise, use React.use() to suspend
35
- // If it returns a Store directly, use it immediately
36
27
  const loadedStore = storeOrPromise instanceof Promise ? React.use(storeOrPromise) : storeOrPromise
37
28
 
38
29
  return withReactApi(loadedStore)
@@ -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(() => {