@livestore/react 0.4.0-dev.2 → 0.4.0-dev.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/LiveStoreContext.d.ts +27 -0
- package/dist/LiveStoreContext.d.ts.map +1 -1
- package/dist/LiveStoreContext.js +18 -0
- package/dist/LiveStoreContext.js.map +1 -1
- package/dist/LiveStoreProvider.d.ts +14 -8
- package/dist/LiveStoreProvider.d.ts.map +1 -1
- package/dist/LiveStoreProvider.js +40 -24
- package/dist/LiveStoreProvider.js.map +1 -1
- package/dist/LiveStoreProvider.test.js +7 -7
- package/dist/LiveStoreProvider.test.js.map +1 -1
- package/dist/__tests__/fixture.d.ts +34 -12
- package/dist/__tests__/fixture.d.ts.map +1 -1
- package/dist/__tests__/fixture.js +13 -5
- package/dist/__tests__/fixture.js.map +1 -1
- package/dist/experimental/components/LiveList.js +1 -1
- package/dist/experimental/mod.d.ts +1 -0
- package/dist/experimental/mod.d.ts.map +1 -1
- package/dist/experimental/mod.js +1 -0
- package/dist/experimental/mod.js.map +1 -1
- package/dist/experimental/multi-store/StoreRegistry.d.ts +105 -0
- package/dist/experimental/multi-store/StoreRegistry.d.ts.map +1 -0
- package/dist/experimental/multi-store/StoreRegistry.js +184 -0
- package/dist/experimental/multi-store/StoreRegistry.js.map +1 -0
- package/dist/experimental/multi-store/StoreRegistry.test.d.ts +2 -0
- package/dist/experimental/multi-store/StoreRegistry.test.d.ts.map +1 -0
- package/dist/experimental/multi-store/StoreRegistry.test.js +381 -0
- package/dist/experimental/multi-store/StoreRegistry.test.js.map +1 -0
- package/dist/experimental/multi-store/StoreRegistryContext.d.ts +10 -0
- package/dist/experimental/multi-store/StoreRegistryContext.d.ts.map +1 -0
- package/dist/experimental/multi-store/StoreRegistryContext.js +15 -0
- package/dist/experimental/multi-store/StoreRegistryContext.js.map +1 -0
- package/dist/experimental/multi-store/mod.d.ts +6 -0
- package/dist/experimental/multi-store/mod.d.ts.map +1 -0
- package/dist/experimental/multi-store/mod.js +6 -0
- package/dist/experimental/multi-store/mod.js.map +1 -0
- package/dist/experimental/multi-store/storeOptions.d.ts +4 -0
- package/dist/experimental/multi-store/storeOptions.d.ts.map +1 -0
- package/dist/experimental/multi-store/storeOptions.js +4 -0
- package/dist/experimental/multi-store/storeOptions.js.map +1 -0
- package/dist/experimental/multi-store/types.d.ts +25 -0
- package/dist/experimental/multi-store/types.d.ts.map +1 -0
- package/dist/experimental/multi-store/types.js +2 -0
- package/dist/experimental/multi-store/types.js.map +1 -0
- package/dist/experimental/multi-store/useStore.d.ts +11 -0
- package/dist/experimental/multi-store/useStore.d.ts.map +1 -0
- package/dist/experimental/multi-store/useStore.js +16 -0
- package/dist/experimental/multi-store/useStore.js.map +1 -0
- package/dist/experimental/multi-store/useStore.test.d.ts +2 -0
- package/dist/experimental/multi-store/useStore.test.d.ts.map +1 -0
- package/dist/experimental/multi-store/useStore.test.js +198 -0
- package/dist/experimental/multi-store/useStore.test.js.map +1 -0
- package/dist/mod.d.ts +1 -1
- package/dist/mod.d.ts.map +1 -1
- package/dist/mod.js.map +1 -1
- package/dist/useClientDocument.d.ts +43 -13
- package/dist/useClientDocument.d.ts.map +1 -1
- package/dist/useClientDocument.js +4 -5
- package/dist/useClientDocument.js.map +1 -1
- package/dist/useClientDocument.test.js +29 -7
- package/dist/useClientDocument.test.js.map +1 -1
- package/dist/useQuery.d.ts +28 -6
- package/dist/useQuery.d.ts.map +1 -1
- package/dist/useQuery.js +63 -18
- package/dist/useQuery.js.map +1 -1
- package/dist/useQuery.test.js +35 -11
- package/dist/useQuery.test.js.map +1 -1
- package/dist/useRcResource.test.js +1 -1
- package/dist/useStore.d.ts +53 -1
- package/dist/useStore.d.ts.map +1 -1
- package/dist/useStore.js +52 -1
- package/dist/useStore.js.map +1 -1
- package/package.json +14 -14
- package/src/LiveStoreContext.ts +27 -0
- package/src/LiveStoreProvider.test.tsx +7 -7
- package/src/LiveStoreProvider.tsx +67 -45
- package/src/__snapshots__/useClientDocument.test.tsx.snap +208 -100
- package/src/__snapshots__/useQuery.test.tsx.snap +400 -128
- package/src/__tests__/fixture.tsx +23 -24
- package/src/experimental/components/LiveList.tsx +1 -1
- package/src/experimental/mod.ts +1 -0
- package/src/experimental/multi-store/StoreRegistry.test.ts +518 -0
- package/src/experimental/multi-store/StoreRegistry.ts +253 -0
- package/src/experimental/multi-store/StoreRegistryContext.tsx +23 -0
- package/src/experimental/multi-store/mod.ts +5 -0
- package/src/experimental/multi-store/storeOptions.ts +8 -0
- package/src/experimental/multi-store/types.ts +37 -0
- package/src/experimental/multi-store/useStore.test.tsx +269 -0
- package/src/experimental/multi-store/useStore.ts +26 -0
- package/src/mod.ts +2 -1
- package/src/useClientDocument.test.tsx +105 -75
- package/src/useClientDocument.ts +58 -13
- package/src/useQuery.test.tsx +62 -11
- package/src/useQuery.ts +98 -27
- package/src/useRcResource.test.tsx +1 -1
- package/src/useStore.ts +55 -3
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { OtelLiveDummy, UnknownError } from '@livestore/common'
|
|
2
|
+
import type { LiveStoreSchema } from '@livestore/common/schema'
|
|
3
|
+
import { createStore, type Store } from '@livestore/livestore'
|
|
4
|
+
import {
|
|
5
|
+
Cause,
|
|
6
|
+
Effect,
|
|
7
|
+
Equal,
|
|
8
|
+
Exit,
|
|
9
|
+
Fiber,
|
|
10
|
+
Hash,
|
|
11
|
+
Layer,
|
|
12
|
+
ManagedRuntime,
|
|
13
|
+
type OtelTracer,
|
|
14
|
+
RcMap,
|
|
15
|
+
Runtime,
|
|
16
|
+
type Scope,
|
|
17
|
+
} from '@livestore/utils/effect'
|
|
18
|
+
import type { CachedStoreOptions } from './types.ts'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Default time to keep unused stores in cache.
|
|
22
|
+
*
|
|
23
|
+
* - Browser: 60 seconds (60,000ms)
|
|
24
|
+
* - SSR: Infinity (disables disposal to avoid disposing stores before server render completes)
|
|
25
|
+
*
|
|
26
|
+
* @internal Exported primarily for testing purposes.
|
|
27
|
+
*/
|
|
28
|
+
export const DEFAULT_UNUSED_CACHE_TIME = typeof window === 'undefined' ? Number.POSITIVE_INFINITY : 60_000
|
|
29
|
+
|
|
30
|
+
type DefaultStoreOptions = Partial<
|
|
31
|
+
Pick<
|
|
32
|
+
CachedStoreOptions,
|
|
33
|
+
'batchUpdates' | 'disableDevtools' | 'confirmUnsavedChanges' | 'syncPayload' | 'debug' | 'otelOptions'
|
|
34
|
+
>
|
|
35
|
+
> & {
|
|
36
|
+
/**
|
|
37
|
+
* The time in milliseconds that unused stores remain in memory.
|
|
38
|
+
* When a store becomes unused (no active retentions), it will be disposed
|
|
39
|
+
* after this duration.
|
|
40
|
+
*
|
|
41
|
+
* Stores transition to the unused state as soon as they have no
|
|
42
|
+
* active retentions, so when all components which use that store
|
|
43
|
+
* have unmounted.
|
|
44
|
+
*
|
|
45
|
+
* @remarks
|
|
46
|
+
* - If set to `Infinity`, will disable disposal
|
|
47
|
+
* - The maximum allowed time is about {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#maximum_delay_value | 24 days}
|
|
48
|
+
*
|
|
49
|
+
* @defaultValue `60_000` (60 seconds) or `Infinity` during SSR to avoid
|
|
50
|
+
* disposing stores before server render completes.
|
|
51
|
+
*/
|
|
52
|
+
unusedCacheTime?: number
|
|
53
|
+
/**
|
|
54
|
+
* Optionally, pass a custom runtime that will be used to run all operations (loading, caching, etc.).
|
|
55
|
+
*/
|
|
56
|
+
runtime?: Runtime.Runtime<Scope.Scope | OtelTracer.OtelTracer>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* RcMap cache key that uses storeId for equality/hashing but carries full options.
|
|
61
|
+
* This allows RcMap to deduplicate by storeId while the lookup function has access to all options.
|
|
62
|
+
*
|
|
63
|
+
* @remarks
|
|
64
|
+
* Only `storeId` is used for equality and hashing. This means if `getOrLoadPromise` is called
|
|
65
|
+
* with different options (e.g., different `adapter`) but the same `storeId`, the cached store
|
|
66
|
+
* from the first call will be returned. This is intentional - a store's identity is determined
|
|
67
|
+
* solely by its `storeId`, and callers should not expect to get different stores by varying
|
|
68
|
+
* other options while keeping the same `storeId`.
|
|
69
|
+
*/
|
|
70
|
+
class StoreCacheKey<TSchema extends LiveStoreSchema = LiveStoreSchema.Any> implements Equal.Equal {
|
|
71
|
+
readonly options: CachedStoreOptions<TSchema>
|
|
72
|
+
|
|
73
|
+
constructor(options: CachedStoreOptions<TSchema>) {
|
|
74
|
+
this.options = options
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Equality is based solely on `storeId`. Other options in `CachedStoreOptions` are ignored
|
|
79
|
+
* for cache key comparison. The first options used for a given `storeId` determine the
|
|
80
|
+
* store's configuration.
|
|
81
|
+
*/
|
|
82
|
+
[Equal.symbol](that: Equal.Equal): boolean {
|
|
83
|
+
return that instanceof StoreCacheKey && this.options.storeId === that.options.storeId
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
[Hash.symbol](): number {
|
|
87
|
+
return Hash.string(this.options.storeId)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Store Registry coordinating store loading, caching, and retention
|
|
93
|
+
*
|
|
94
|
+
* @public
|
|
95
|
+
*/
|
|
96
|
+
export class StoreRegistry {
|
|
97
|
+
/**
|
|
98
|
+
* Reference-counted cache mapping storeId to Store instances.
|
|
99
|
+
* Stores are created on first access and disposed after `unusedCacheTime` when all references are released.
|
|
100
|
+
*/
|
|
101
|
+
#rcMap: RcMap.RcMap<StoreCacheKey<any>, Store, UnknownError>
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Effect runtime providing Scope and OtelTracer for all registry operations.
|
|
105
|
+
* When the runtime's scope closes, all managed stores are automatically shut down.
|
|
106
|
+
*/
|
|
107
|
+
#runtime: Runtime.Runtime<Scope.Scope | OtelTracer.OtelTracer>
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* In-flight loading promises keyed by storeId.
|
|
111
|
+
* Ensures concurrent `getOrLoadPromise` calls receive the same Promise reference.
|
|
112
|
+
*/
|
|
113
|
+
#loadingPromises: Map<string, Promise<Store<any>>> = new Map()
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Creates a new StoreRegistry instance.
|
|
117
|
+
*
|
|
118
|
+
* @param params.defaultOptions - Default options applied to all stores managed by this registry when they are loaded.
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* ```ts
|
|
122
|
+
* const registry = new StoreRegistry({
|
|
123
|
+
* defaultOptions: {
|
|
124
|
+
* batchUpdates,
|
|
125
|
+
* unusedCacheTime: 30_000,
|
|
126
|
+
* }
|
|
127
|
+
* })
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
constructor(params: { defaultOptions?: DefaultStoreOptions } = {}) {
|
|
131
|
+
this.#runtime =
|
|
132
|
+
params.defaultOptions?.runtime ??
|
|
133
|
+
ManagedRuntime.make(Layer.mergeAll(Layer.scope, OtelLiveDummy)).runtimeEffect.pipe(Effect.runSync)
|
|
134
|
+
|
|
135
|
+
this.#rcMap = RcMap.make({
|
|
136
|
+
lookup: (key: StoreCacheKey) =>
|
|
137
|
+
Effect.gen(this, function* () {
|
|
138
|
+
const { options } = key
|
|
139
|
+
|
|
140
|
+
return yield* createStore(options).pipe(Effect.catchAllDefect((cause) => UnknownError.make({ cause })))
|
|
141
|
+
}).pipe(Effect.withSpan(`StoreRegistry.lookup:${key.options.storeId}`)),
|
|
142
|
+
// TODO: Make idleTimeToLive vary for each store when Effect supports per-resource TTL
|
|
143
|
+
// See https://github.com/livestorejs/livestore/issues/917
|
|
144
|
+
idleTimeToLive: params.defaultOptions?.unusedCacheTime ?? DEFAULT_UNUSED_CACHE_TIME,
|
|
145
|
+
}).pipe(Runtime.runSync(this.#runtime))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Gets a cached store or loads a new one, with the store lifetime scoped to the caller.
|
|
150
|
+
*
|
|
151
|
+
* @typeParam TSchema - The schema type for the store
|
|
152
|
+
* @returns An Effect that yields the store, scoped to the provided Scope
|
|
153
|
+
*
|
|
154
|
+
* @remarks
|
|
155
|
+
* - Stores are kept in cache and reused while any scope holds them
|
|
156
|
+
* - When the scope closes, the reference is released; the store is disposed after `unusedCacheTime`
|
|
157
|
+
* if no other scopes retain it
|
|
158
|
+
* - Concurrent calls with the same storeId share the same store instance
|
|
159
|
+
*/
|
|
160
|
+
getOrLoad = <TSchema extends LiveStoreSchema>(
|
|
161
|
+
options: CachedStoreOptions<TSchema>,
|
|
162
|
+
): Effect.Effect<Store<TSchema>, UnknownError, Scope.Scope> =>
|
|
163
|
+
Effect.gen(this, function* () {
|
|
164
|
+
const key = new StoreCacheKey(options)
|
|
165
|
+
const store = yield* RcMap.get(this.#rcMap, key)
|
|
166
|
+
|
|
167
|
+
return store as unknown as Store<TSchema>
|
|
168
|
+
}).pipe(Effect.withSpan(`StoreRegistry.getOrLoad:${options.storeId}`))
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Get or load a store, returning it directly if already loaded or a promise if loading.
|
|
172
|
+
*
|
|
173
|
+
* @typeParam TSchema - The schema type for the store
|
|
174
|
+
* @returns The loaded store if available, or a Promise that resolves to the loaded store
|
|
175
|
+
* @throws unknown loading error
|
|
176
|
+
*
|
|
177
|
+
* @remarks
|
|
178
|
+
* - Returns the store instance directly (synchronous) when already loaded
|
|
179
|
+
* - Returns a stable Promise reference when loading is in progress or needs to be initiated
|
|
180
|
+
* - Throws with the same error instance on subsequent calls after failure
|
|
181
|
+
* - Applies default options from registry config, with call-site options taking precedence
|
|
182
|
+
* - Concurrent calls with the same storeId share the same store instance
|
|
183
|
+
*/
|
|
184
|
+
getOrLoadPromise = <TSchema extends LiveStoreSchema>(
|
|
185
|
+
options: CachedStoreOptions<TSchema>,
|
|
186
|
+
): Store<TSchema> | Promise<Store<TSchema>> => {
|
|
187
|
+
const exit = this.getOrLoad<TSchema>(options).pipe(Effect.scoped, Runtime.runSyncExit(this.#runtime))
|
|
188
|
+
|
|
189
|
+
if (Exit.isSuccess(exit)) return exit.value as Store<TSchema>
|
|
190
|
+
|
|
191
|
+
// Check if the failure is due to async work
|
|
192
|
+
const defect = Cause.dieOption(exit.cause)
|
|
193
|
+
if (defect._tag === 'Some' && Runtime.isAsyncFiberException(defect.value)) {
|
|
194
|
+
const { storeId } = options
|
|
195
|
+
|
|
196
|
+
// Return cached promise if one exists (ensures concurrent calls get the same Promise reference)
|
|
197
|
+
const cached = this.#loadingPromises.get(storeId)
|
|
198
|
+
if (cached) return cached as Promise<Store<TSchema>>
|
|
199
|
+
|
|
200
|
+
// Create and cache the promise
|
|
201
|
+
const fiber = defect.value.fiber
|
|
202
|
+
const promise = Fiber.join(fiber)
|
|
203
|
+
.pipe(Runtime.runPromise(this.#runtime))
|
|
204
|
+
.finally(() => this.#loadingPromises.delete(storeId)) as Promise<Store<TSchema>>
|
|
205
|
+
|
|
206
|
+
this.#loadingPromises.set(storeId, promise)
|
|
207
|
+
return promise
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Handle synchronous failure
|
|
211
|
+
throw Cause.squash(exit.cause)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Retains the store in cache until the returned release function is called.
|
|
216
|
+
*
|
|
217
|
+
* @returns A release function that, when called, removes this retention hold
|
|
218
|
+
*
|
|
219
|
+
* @remarks
|
|
220
|
+
* - Multiple retains on the same store are independent; each must be released separately
|
|
221
|
+
* - If the store isn't cached yet, it will be loaded and then retained
|
|
222
|
+
*/
|
|
223
|
+
retain = (options: CachedStoreOptions<any>): (() => void) => {
|
|
224
|
+
const release = Effect.gen(this, function* () {
|
|
225
|
+
const key = new StoreCacheKey(options)
|
|
226
|
+
yield* RcMap.get(this.#rcMap, key)
|
|
227
|
+
// Effect.never suspends indefinitely, keeping the RcMap reference alive.
|
|
228
|
+
// When `release()` is called, the fiber is interrupted, closing the scope
|
|
229
|
+
// and releasing the RcMap entry (which may trigger disposal after idleTimeToLive).
|
|
230
|
+
yield* Effect.never
|
|
231
|
+
}).pipe(Effect.scoped, Runtime.runCallback(this.#runtime))
|
|
232
|
+
|
|
233
|
+
return () => release()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Warms the cache for a store without adding a retention.
|
|
238
|
+
*
|
|
239
|
+
* @typeParam TSchema - The schema of the store to preload
|
|
240
|
+
* @returns A promise that resolves when the loading is complete (success or failure)
|
|
241
|
+
*
|
|
242
|
+
* @remarks
|
|
243
|
+
* - We don't return the store or throw as this is a fire-and-forget operation.
|
|
244
|
+
* - If the entry remains unused after preload resolves/rejects, it is scheduled for disposal.
|
|
245
|
+
*/
|
|
246
|
+
preload = async <TSchema extends LiveStoreSchema>(options: CachedStoreOptions<TSchema>): Promise<void> => {
|
|
247
|
+
try {
|
|
248
|
+
await this.getOrLoadPromise(options)
|
|
249
|
+
} catch {
|
|
250
|
+
// Do nothing; preload is best-effort
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -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 <StoreRegistryProvider>')
|
|
21
|
+
|
|
22
|
+
return storeRegistry
|
|
23
|
+
}
|
|
@@ -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,37 @@
|
|
|
1
|
+
import type { LiveStoreSchema } from '@livestore/common/schema'
|
|
2
|
+
import type { CreateStoreOptions, OtelOptions } from '@livestore/livestore'
|
|
3
|
+
|
|
4
|
+
export type CachedStoreOptions<TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TContext = {}> = Pick<
|
|
5
|
+
CreateStoreOptions<TSchema, TContext>,
|
|
6
|
+
| 'storeId'
|
|
7
|
+
| 'schema'
|
|
8
|
+
| 'adapter'
|
|
9
|
+
| 'boot'
|
|
10
|
+
| 'batchUpdates'
|
|
11
|
+
| 'disableDevtools'
|
|
12
|
+
| 'confirmUnsavedChanges'
|
|
13
|
+
| 'syncPayload'
|
|
14
|
+
| 'debug'
|
|
15
|
+
| 'shutdownDeferred'
|
|
16
|
+
> & {
|
|
17
|
+
signal?: AbortSignal
|
|
18
|
+
otelOptions?: Partial<OtelOptions>
|
|
19
|
+
/**
|
|
20
|
+
* The time in milliseconds that this store should remain
|
|
21
|
+
* in memory after becoming unused. When this store becomes
|
|
22
|
+
* unused (no active retentions), it will be disposed after this duration.
|
|
23
|
+
*
|
|
24
|
+
* Stores transition to the unused state as soon as they have no
|
|
25
|
+
* active retentions, so when all components which use that store
|
|
26
|
+
* have unmounted.
|
|
27
|
+
*
|
|
28
|
+
* @remarks
|
|
29
|
+
* - When different `unusedCacheTime` values are used for the same store, the longest one will be used.
|
|
30
|
+
* - If set to `Infinity`, will disable automatic disposal
|
|
31
|
+
* - The maximum allowed time is about {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#maximum_delay_value | 24 days}
|
|
32
|
+
*
|
|
33
|
+
* @defaultValue `60_000` (60 seconds) or `Infinity` during SSR to avoid
|
|
34
|
+
* disposing stores before server render completes.
|
|
35
|
+
*/
|
|
36
|
+
unusedCacheTime?: number
|
|
37
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { makeInMemoryAdapter } from '@livestore/adapter-web'
|
|
2
|
+
import type { Store } from '@livestore/livestore'
|
|
3
|
+
import { StoreInternalsSymbol } from '@livestore/livestore'
|
|
4
|
+
import { shouldNeverHappen } from '@livestore/utils'
|
|
5
|
+
import { act, type RenderHookResult, type RenderResult, render, renderHook, waitFor } from '@testing-library/react'
|
|
6
|
+
import * as React from 'react'
|
|
7
|
+
import { describe, expect, it } from 'vitest'
|
|
8
|
+
import { schema } from '../../__tests__/fixture.tsx'
|
|
9
|
+
import { StoreRegistry } from './StoreRegistry.ts'
|
|
10
|
+
import { StoreRegistryProvider } from './StoreRegistryContext.tsx'
|
|
11
|
+
import { storeOptions } from './storeOptions.ts'
|
|
12
|
+
import type { CachedStoreOptions } from './types.ts'
|
|
13
|
+
import { useStore } from './useStore.ts'
|
|
14
|
+
|
|
15
|
+
describe('experimental useStore', () => {
|
|
16
|
+
it('should return the same promise instance for concurrent getOrLoadStore calls', async () => {
|
|
17
|
+
const registry = new StoreRegistry()
|
|
18
|
+
const options = testStoreOptions()
|
|
19
|
+
|
|
20
|
+
// Make two concurrent calls during loading
|
|
21
|
+
const firstStore = registry.getOrLoadPromise(options)
|
|
22
|
+
const secondStore = registry.getOrLoadPromise(options)
|
|
23
|
+
|
|
24
|
+
// Both should be promises (store is loading)
|
|
25
|
+
expect(firstStore).toBeInstanceOf(Promise)
|
|
26
|
+
expect(secondStore).toBeInstanceOf(Promise)
|
|
27
|
+
|
|
28
|
+
// EXPECTED BEHAVIOR: Same promise instance for React.use() compatibility
|
|
29
|
+
// ACTUAL BEHAVIOR: Different promise instances (Effect.runPromise creates new wrapper)
|
|
30
|
+
expect(firstStore).toBe(secondStore)
|
|
31
|
+
|
|
32
|
+
// Cleanup
|
|
33
|
+
await firstStore
|
|
34
|
+
await cleanupAfterUnmount(() => {})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('works with Suspense boundary', async () => {
|
|
38
|
+
const registry = new StoreRegistry()
|
|
39
|
+
const options = testStoreOptions()
|
|
40
|
+
|
|
41
|
+
let view: RenderResult | undefined
|
|
42
|
+
await act(async () => {
|
|
43
|
+
view = render(
|
|
44
|
+
<StoreRegistryProvider storeRegistry={registry}>
|
|
45
|
+
<React.Suspense fallback={<div data-testid="fallback" />}>
|
|
46
|
+
<StoreConsumer options={options} />
|
|
47
|
+
</React.Suspense>
|
|
48
|
+
</StoreRegistryProvider>,
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
const renderedView = view ?? shouldNeverHappen('render failed')
|
|
52
|
+
|
|
53
|
+
// After loading completes, should show the actual content
|
|
54
|
+
await waitForSuspenseResolved(renderedView)
|
|
55
|
+
expect(renderedView.getByTestId('ready')).toBeDefined()
|
|
56
|
+
|
|
57
|
+
await cleanupAfterUnmount(() => renderedView.unmount())
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('does not re-suspend on subsequent renders when store is already loaded', async () => {
|
|
61
|
+
const registry = new StoreRegistry()
|
|
62
|
+
const options = testStoreOptions()
|
|
63
|
+
|
|
64
|
+
const Wrapper = ({ opts }: { opts: CachedStoreOptions<typeof schema> }) => (
|
|
65
|
+
<StoreRegistryProvider storeRegistry={registry}>
|
|
66
|
+
<React.Suspense fallback={<div data-testid="fallback" />}>
|
|
67
|
+
<StoreConsumer options={opts} />
|
|
68
|
+
</React.Suspense>
|
|
69
|
+
</StoreRegistryProvider>
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
let view: RenderResult | undefined
|
|
73
|
+
await act(async () => {
|
|
74
|
+
view = render(<Wrapper opts={options} />)
|
|
75
|
+
})
|
|
76
|
+
const renderedView = view ?? shouldNeverHappen('render failed')
|
|
77
|
+
|
|
78
|
+
// Wait for initial load
|
|
79
|
+
await waitForSuspenseResolved(renderedView)
|
|
80
|
+
expect(renderedView.getByTestId('ready')).toBeDefined()
|
|
81
|
+
|
|
82
|
+
// Rerender with new options object (but same storeId)
|
|
83
|
+
await act(async () => {
|
|
84
|
+
renderedView.rerender(<Wrapper opts={{ ...options }} />)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// Should not show fallback
|
|
88
|
+
expect(renderedView.queryByTestId('fallback')).toBeNull()
|
|
89
|
+
expect(renderedView.getByTestId('ready')).toBeDefined()
|
|
90
|
+
|
|
91
|
+
await cleanupAfterUnmount(() => renderedView.unmount())
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('throws when store loading fails', async () => {
|
|
95
|
+
const registry = new StoreRegistry()
|
|
96
|
+
const badOptions = testStoreOptions({
|
|
97
|
+
// @ts-expect-error - intentionally passing invalid adapter to trigger error
|
|
98
|
+
adapter: null,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// Pre-load the store to cache the error (error happens synchronously)
|
|
102
|
+
expect(() => registry.getOrLoadPromise(badOptions)).toThrow()
|
|
103
|
+
|
|
104
|
+
// Now when useStore tries to get it, it should throw synchronously
|
|
105
|
+
expect(() =>
|
|
106
|
+
renderHook(() => useStore(badOptions), {
|
|
107
|
+
wrapper: makeProvider(registry),
|
|
108
|
+
}),
|
|
109
|
+
).toThrow()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it.each([
|
|
113
|
+
{ label: 'non-strict mode', strictMode: false },
|
|
114
|
+
{ label: 'strict mode', strictMode: true },
|
|
115
|
+
])('works in $label', async ({ strictMode }) => {
|
|
116
|
+
const registry = new StoreRegistry()
|
|
117
|
+
const options = testStoreOptions()
|
|
118
|
+
|
|
119
|
+
let hook: RenderHookResult<Store<typeof schema>, CachedStoreOptions<typeof schema>> | undefined
|
|
120
|
+
await act(async () => {
|
|
121
|
+
hook = renderHook(() => useStore(options), {
|
|
122
|
+
wrapper: makeProvider(registry, { suspense: true }),
|
|
123
|
+
reactStrictMode: strictMode,
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
const { result, unmount } = hook ?? shouldNeverHappen('renderHook failed')
|
|
127
|
+
|
|
128
|
+
// Wait for store to be ready
|
|
129
|
+
await waitForStoreReady(result)
|
|
130
|
+
expect(result.current[StoreInternalsSymbol].clientSession).toBeDefined()
|
|
131
|
+
|
|
132
|
+
await cleanupAfterUnmount(unmount)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('handles switching between different storeId values', async () => {
|
|
136
|
+
const registry = new StoreRegistry()
|
|
137
|
+
|
|
138
|
+
const optionsA = testStoreOptions({ storeId: 'store-a' })
|
|
139
|
+
const optionsB = testStoreOptions({ storeId: 'store-b' })
|
|
140
|
+
|
|
141
|
+
let hook: RenderHookResult<Store<typeof schema>, CachedStoreOptions<typeof schema>> | undefined
|
|
142
|
+
await act(async () => {
|
|
143
|
+
hook = renderHook((opts) => useStore(opts), {
|
|
144
|
+
initialProps: optionsA,
|
|
145
|
+
wrapper: makeProvider(registry, { suspense: true }),
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
const { result, rerender, unmount } = hook ?? shouldNeverHappen('renderHook failed')
|
|
149
|
+
|
|
150
|
+
// Wait for first store to load
|
|
151
|
+
await waitForStoreReady(result)
|
|
152
|
+
const storeA = result.current
|
|
153
|
+
expect(storeA[StoreInternalsSymbol].clientSession).toBeDefined()
|
|
154
|
+
|
|
155
|
+
// Switch to different storeId
|
|
156
|
+
await act(async () => {
|
|
157
|
+
rerender(optionsB)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// Wait for second store to load and verify it's different from the first
|
|
161
|
+
await waitFor(() => {
|
|
162
|
+
expect(result.current).not.toBe(storeA)
|
|
163
|
+
expect(result.current?.[StoreInternalsSymbol].clientSession).toBeDefined()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const storeB = result.current
|
|
167
|
+
expect(storeB[StoreInternalsSymbol].clientSession).toBeDefined()
|
|
168
|
+
expect(storeB).not.toBe(storeA)
|
|
169
|
+
|
|
170
|
+
await cleanupAfterUnmount(unmount)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// useStore doesn't handle unusedCacheTime=0 correctly because retain is called in useEffect (after render)
|
|
174
|
+
// See https://github.com/livestorejs/livestore/issues/916
|
|
175
|
+
it.skip('should load store with unusedCacheTime set to 0', async () => {
|
|
176
|
+
const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime: 0 } })
|
|
177
|
+
const options = testStoreOptions({ unusedCacheTime: 0 })
|
|
178
|
+
|
|
179
|
+
const StoreConsumerWithVerification = ({ opts }: { opts: CachedStoreOptions<typeof schema> }) => {
|
|
180
|
+
const store = useStore(opts)
|
|
181
|
+
// Verify store is usable - access internals to confirm it's not disposed
|
|
182
|
+
const clientSession = store[StoreInternalsSymbol].clientSession
|
|
183
|
+
return <div data-testid="ready" data-has-session={String(clientSession !== undefined)} />
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let view: RenderResult | undefined
|
|
187
|
+
await act(async () => {
|
|
188
|
+
view = render(
|
|
189
|
+
<StoreRegistryProvider storeRegistry={registry}>
|
|
190
|
+
<React.Suspense fallback={<div data-testid="fallback" />}>
|
|
191
|
+
<StoreConsumerWithVerification opts={options} />
|
|
192
|
+
</React.Suspense>
|
|
193
|
+
</StoreRegistryProvider>,
|
|
194
|
+
)
|
|
195
|
+
})
|
|
196
|
+
const renderedView = view ?? shouldNeverHappen('render failed')
|
|
197
|
+
|
|
198
|
+
await waitForSuspenseResolved(renderedView)
|
|
199
|
+
|
|
200
|
+
// Store should be usable while component is mounted
|
|
201
|
+
const readyElement = renderedView.getByTestId('ready')
|
|
202
|
+
expect(readyElement.getAttribute('data-has-session')).toBe('true')
|
|
203
|
+
|
|
204
|
+
// Allow some time to pass to ensure store isn't prematurely disposed
|
|
205
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
206
|
+
|
|
207
|
+
// Store should still be usable after waiting
|
|
208
|
+
expect(readyElement.getAttribute('data-has-session')).toBe('true')
|
|
209
|
+
|
|
210
|
+
await cleanupAfterUnmount(() => renderedView.unmount())
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
const StoreConsumer = ({ options }: { options: CachedStoreOptions<any> }) => {
|
|
215
|
+
useStore(options)
|
|
216
|
+
return <div data-testid="ready" />
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const makeProvider =
|
|
220
|
+
(registry: StoreRegistry, { suspense = false }: { suspense?: boolean } = {}) =>
|
|
221
|
+
({ children }: { children: React.ReactNode }) => {
|
|
222
|
+
let content = <StoreRegistryProvider storeRegistry={registry}>{children}</StoreRegistryProvider>
|
|
223
|
+
|
|
224
|
+
if (suspense) {
|
|
225
|
+
content = <React.Suspense fallback={null}>{content}</React.Suspense>
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return content
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let testStoreCounter = 0
|
|
232
|
+
|
|
233
|
+
const testStoreOptions = (overrides: Partial<CachedStoreOptions<typeof schema>> = {}) =>
|
|
234
|
+
storeOptions({
|
|
235
|
+
storeId: overrides.storeId ?? `test-store-${testStoreCounter++}`,
|
|
236
|
+
schema,
|
|
237
|
+
adapter: makeInMemoryAdapter(),
|
|
238
|
+
...overrides,
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Cleans up after component unmount and waits for pending operations to settle.
|
|
243
|
+
*
|
|
244
|
+
* When components using stores unmount, the StoreRegistry schedules garbage collection
|
|
245
|
+
* timers for inactive stores. This helper waits for those timers to complete naturally.
|
|
246
|
+
*/
|
|
247
|
+
const cleanupAfterUnmount = async (cleanup: () => void): Promise<void> => {
|
|
248
|
+
cleanup()
|
|
249
|
+
// Allow any pending microtasks/timers to settle
|
|
250
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Waits for React Suspense fallback to resolve and the actual content to render.
|
|
255
|
+
*/
|
|
256
|
+
const waitForSuspenseResolved = async (view: RenderResult): Promise<void> => {
|
|
257
|
+
await waitFor(() => expect(view.queryByTestId('fallback')).toBeNull())
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Waits for a store to be fully loaded and ready to use.
|
|
262
|
+
* The store is considered ready when it has a defined clientSession.
|
|
263
|
+
*/
|
|
264
|
+
const waitForStoreReady = async (result: { current: Store<any> }): Promise<void> => {
|
|
265
|
+
await waitFor(() => {
|
|
266
|
+
expect(result.current).not.toBeNull()
|
|
267
|
+
expect(result.current[StoreInternalsSymbol].clientSession).toBeDefined()
|
|
268
|
+
})
|
|
269
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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 and 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
|
+
React.useEffect(() => storeRegistry.retain(options), [storeRegistry, options])
|
|
20
|
+
|
|
21
|
+
const storeOrPromise = React.useMemo(() => storeRegistry.getOrLoadPromise(options), [storeRegistry, options])
|
|
22
|
+
|
|
23
|
+
const store = storeOrPromise instanceof Promise ? React.use(storeOrPromise) : storeOrPromise
|
|
24
|
+
|
|
25
|
+
return withReactApi(store)
|
|
26
|
+
}
|
package/src/mod.ts
CHANGED
|
@@ -3,8 +3,9 @@ export { LiveStoreProvider } from './LiveStoreProvider.tsx'
|
|
|
3
3
|
export {
|
|
4
4
|
type Dispatch,
|
|
5
5
|
type SetStateAction,
|
|
6
|
+
type SetStateActionPartial,
|
|
6
7
|
type StateSetters,
|
|
7
|
-
type
|
|
8
|
+
type UseClientDocumentResult,
|
|
8
9
|
useClientDocument,
|
|
9
10
|
} from './useClientDocument.ts'
|
|
10
11
|
export { useQuery, useQueryRef } from './useQuery.ts'
|