@livestore/react 0.4.0-dev.15 → 0.4.0-dev.17
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/LiveStoreProvider.d.ts +3 -3
- package/dist/LiveStoreProvider.d.ts.map +1 -1
- package/dist/LiveStoreProvider.js +9 -5
- package/dist/LiveStoreProvider.js.map +1 -1
- package/dist/LiveStoreProvider.test.js +2 -2
- package/dist/LiveStoreProvider.test.js.map +1 -1
- package/dist/experimental/multi-store/StoreRegistry.d.ts.map +1 -1
- package/dist/experimental/multi-store/StoreRegistry.js +58 -58
- package/dist/experimental/multi-store/StoreRegistry.js.map +1 -1
- 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 +373 -0
- package/dist/experimental/multi-store/StoreRegistry.test.js.map +1 -0
- package/dist/experimental/multi-store/useStore.d.ts.map +1 -1
- package/dist/experimental/multi-store/useStore.js +7 -3
- package/dist/experimental/multi-store/useStore.js.map +1 -1
- 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 +144 -0
- package/dist/experimental/multi-store/useStore.test.js.map +1 -0
- package/dist/useClientDocument.js +1 -1
- package/dist/useClientDocument.js.map +1 -1
- package/dist/useClientDocument.test.js +3 -2
- package/dist/useClientDocument.test.js.map +1 -1
- package/dist/useQuery.d.ts.map +1 -1
- package/dist/useQuery.js +3 -3
- package/dist/useQuery.js.map +1 -1
- package/dist/useQuery.test.js +9 -9
- package/dist/useQuery.test.js.map +1 -1
- package/package.json +6 -6
- package/src/LiveStoreProvider.test.tsx +2 -2
- package/src/LiveStoreProvider.tsx +18 -20
- package/src/experimental/multi-store/StoreRegistry.test.ts +511 -0
- package/src/experimental/multi-store/StoreRegistry.ts +63 -64
- package/src/experimental/multi-store/useStore.test.tsx +197 -0
- package/src/experimental/multi-store/useStore.ts +7 -3
- package/src/useClientDocument.test.tsx +3 -2
- package/src/useClientDocument.ts +1 -1
- package/src/useQuery.test.tsx +15 -9
- package/src/useQuery.ts +4 -3
|
@@ -9,24 +9,62 @@ type StoreEntryState<TSchema extends LiveStoreSchema> =
|
|
|
9
9
|
| { status: 'error'; error: unknown }
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
12
|
+
* Default garbage collection time for inactive stores.
|
|
13
13
|
*
|
|
14
|
+
* - Browser: 60 seconds (60,000ms)
|
|
15
|
+
* - SSR: Infinity (disables GC to avoid disposing stores before server render completes)
|
|
16
|
+
*
|
|
17
|
+
* @internal Exported primarily for testing purposes.
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_GC_TIME = typeof window === 'undefined' ? Number.POSITIVE_INFINITY : 60_000
|
|
20
|
+
|
|
21
|
+
/**
|
|
14
22
|
* @typeParam TSchema - The schema for this entry's store.
|
|
15
23
|
* @internal
|
|
16
24
|
*/
|
|
17
25
|
class StoreEntry<TSchema extends LiveStoreSchema = LiveStoreSchema> {
|
|
26
|
+
readonly #storeId: StoreId
|
|
27
|
+
readonly #cache: StoreCache
|
|
28
|
+
|
|
18
29
|
#state: StoreEntryState<TSchema> = { status: 'idle' }
|
|
19
30
|
|
|
31
|
+
#gcTime?: number
|
|
32
|
+
#gcTimeout?: ReturnType<typeof setTimeout> | null
|
|
33
|
+
|
|
20
34
|
/**
|
|
21
35
|
* Set of subscriber callbacks to notify on state changes.
|
|
22
36
|
*/
|
|
23
|
-
#subscribers = new Set<() => void>()
|
|
37
|
+
readonly #subscribers = new Set<() => void>()
|
|
24
38
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
39
|
+
constructor(storeId: StoreId, cache: StoreCache) {
|
|
40
|
+
this.#storeId = storeId
|
|
41
|
+
this.#cache = cache
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#scheduleGC = (): void => {
|
|
45
|
+
this.#cancelGC()
|
|
46
|
+
|
|
47
|
+
const effectiveGcTime = this.#gcTime === undefined ? DEFAULT_GC_TIME : this.#gcTime
|
|
48
|
+
|
|
49
|
+
if (effectiveGcTime === Number.POSITIVE_INFINITY) return // Infinity disables GC
|
|
50
|
+
|
|
51
|
+
this.#gcTimeout = setTimeout(() => {
|
|
52
|
+
this.#gcTimeout = null
|
|
53
|
+
|
|
54
|
+
// Re-check to avoid racing with a new subscription
|
|
55
|
+
if (this.#subscribers.size > 0) return
|
|
56
|
+
|
|
57
|
+
void this.#shutdown().finally(() => {
|
|
58
|
+
// Double-check again just in case shutdown was slow
|
|
59
|
+
if (this.#subscribers.size === 0) this.#cache.delete(this.#storeId)
|
|
60
|
+
})
|
|
61
|
+
}, effectiveGcTime)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#cancelGC = (): void => {
|
|
65
|
+
if (!this.#gcTimeout) return
|
|
66
|
+
clearTimeout(this.#gcTimeout)
|
|
67
|
+
this.#gcTimeout = null
|
|
30
68
|
}
|
|
31
69
|
|
|
32
70
|
/**
|
|
@@ -54,11 +92,6 @@ class StoreEntry<TSchema extends LiveStoreSchema = LiveStoreSchema> {
|
|
|
54
92
|
this.#notify()
|
|
55
93
|
}
|
|
56
94
|
|
|
57
|
-
#reset = (): void => {
|
|
58
|
-
this.#state = { status: 'idle' }
|
|
59
|
-
this.#notify()
|
|
60
|
-
}
|
|
61
|
-
|
|
62
95
|
/**
|
|
63
96
|
* Notifies all subscribers of state changes.
|
|
64
97
|
*
|
|
@@ -82,9 +115,12 @@ class StoreEntry<TSchema extends LiveStoreSchema = LiveStoreSchema> {
|
|
|
82
115
|
* @returns Unsubscribe function
|
|
83
116
|
*/
|
|
84
117
|
subscribe = (listener: () => void): Unsubscribe => {
|
|
118
|
+
this.#cancelGC()
|
|
85
119
|
this.#subscribers.add(listener)
|
|
86
120
|
return () => {
|
|
87
121
|
this.#subscribers.delete(listener)
|
|
122
|
+
// If no more subscribers remain, schedule GC
|
|
123
|
+
if (this.#subscribers.size === 0) this.#scheduleGC()
|
|
88
124
|
}
|
|
89
125
|
}
|
|
90
126
|
|
|
@@ -101,6 +137,8 @@ class StoreEntry<TSchema extends LiveStoreSchema = LiveStoreSchema> {
|
|
|
101
137
|
* - Invokes onSettle callback for GC scheduling when needed
|
|
102
138
|
*/
|
|
103
139
|
getOrLoad = (options: CachedStoreOptions<TSchema>): Store<TSchema> | Promise<Store<TSchema>> => {
|
|
140
|
+
if (options.gcTime !== undefined) this.#gcTime = Math.max(this.#gcTime ?? 0, options.gcTime)
|
|
141
|
+
|
|
104
142
|
if (this.#state.status === 'success') return this.#state.store
|
|
105
143
|
if (this.#state.status === 'loading') return this.#state.promise
|
|
106
144
|
if (this.#state.status === 'error') throw this.#state.error
|
|
@@ -114,17 +152,21 @@ class StoreEntry<TSchema extends LiveStoreSchema = LiveStoreSchema> {
|
|
|
114
152
|
this.#setError(error)
|
|
115
153
|
throw error
|
|
116
154
|
})
|
|
155
|
+
.finally(() => {
|
|
156
|
+
// The store entry may have become inactive (no subscribers) while loading the store
|
|
157
|
+
if (this.#subscribers.size === 0) this.#scheduleGC()
|
|
158
|
+
})
|
|
117
159
|
|
|
118
160
|
this.#setPromise(promise)
|
|
119
161
|
|
|
120
162
|
return promise
|
|
121
163
|
}
|
|
122
164
|
|
|
123
|
-
shutdown = async (): Promise<void> => {
|
|
165
|
+
#shutdown = async (): Promise<void> => {
|
|
124
166
|
if (this.#state.status !== 'success') return
|
|
125
|
-
await this.#state.store.shutdownPromise()
|
|
126
|
-
|
|
127
|
-
|
|
167
|
+
await this.#state.store.shutdownPromise().catch((reason) => {
|
|
168
|
+
console.warn(`Store ${this.#storeId} failed to shutdown cleanly during GC:`, reason)
|
|
169
|
+
})
|
|
128
170
|
}
|
|
129
171
|
}
|
|
130
172
|
|
|
@@ -147,7 +189,7 @@ class StoreCache {
|
|
|
147
189
|
let entry = this.#entries.get(storeId) as StoreEntry<TSchema> | undefined
|
|
148
190
|
|
|
149
191
|
if (!entry) {
|
|
150
|
-
entry = new StoreEntry<TSchema>()
|
|
192
|
+
entry = new StoreEntry<TSchema>(storeId, this)
|
|
151
193
|
this.#entries.set(storeId, entry as unknown as StoreEntry)
|
|
152
194
|
}
|
|
153
195
|
|
|
@@ -158,20 +200,12 @@ class StoreCache {
|
|
|
158
200
|
* Removes an entry from the cache.
|
|
159
201
|
*
|
|
160
202
|
* @param storeId - The ID of the store to remove
|
|
161
|
-
*
|
|
162
|
-
* @remarks
|
|
163
|
-
* - Invokes shutdown on the store before removal.
|
|
164
203
|
*/
|
|
165
|
-
|
|
166
|
-
const entry = this.#entries.get(storeId)
|
|
167
|
-
if (!entry) return
|
|
168
|
-
void entry.shutdown()
|
|
204
|
+
delete = (storeId: StoreId): void => {
|
|
169
205
|
this.#entries.delete(storeId)
|
|
170
206
|
}
|
|
171
207
|
}
|
|
172
208
|
|
|
173
|
-
const DEFAULT_GC_TIME = typeof window === 'undefined' ? Number.POSITIVE_INFINITY : 60_000
|
|
174
|
-
|
|
175
209
|
type DefaultStoreOptions = Partial<
|
|
176
210
|
Pick<
|
|
177
211
|
CachedStoreOptions<any>,
|
|
@@ -208,7 +242,6 @@ type StoreRegistryConfig = {
|
|
|
208
242
|
*/
|
|
209
243
|
export class StoreRegistry {
|
|
210
244
|
readonly #cache = new StoreCache()
|
|
211
|
-
readonly #gcTimeouts = new Map<StoreId, ReturnType<typeof setTimeout>>()
|
|
212
245
|
readonly #defaultOptions: DefaultStoreOptions
|
|
213
246
|
|
|
214
247
|
constructor({ defaultOptions = {} }: StoreRegistryConfig = {}) {
|
|
@@ -222,23 +255,6 @@ export class StoreRegistry {
|
|
|
222
255
|
...options,
|
|
223
256
|
})
|
|
224
257
|
|
|
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
258
|
/**
|
|
243
259
|
* Get or load a store, returning it directly if loaded or a promise if loading.
|
|
244
260
|
*
|
|
@@ -256,18 +272,9 @@ export class StoreRegistry {
|
|
|
256
272
|
options: CachedStoreOptions<TSchema>,
|
|
257
273
|
): Store<TSchema> | Promise<Store<TSchema>> => {
|
|
258
274
|
const optionsWithDefaults = this.#applyDefaultOptions(options)
|
|
259
|
-
const
|
|
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
|
-
}
|
|
275
|
+
const storeEntry = this.#cache.ensure<TSchema>(optionsWithDefaults.storeId)
|
|
269
276
|
|
|
270
|
-
return
|
|
277
|
+
return storeEntry.getOrLoad(optionsWithDefaults)
|
|
271
278
|
}
|
|
272
279
|
|
|
273
280
|
/**
|
|
@@ -290,15 +297,7 @@ export class StoreRegistry {
|
|
|
290
297
|
|
|
291
298
|
subscribe = <TSchema extends LiveStoreSchema>(storeId: StoreId, listener: () => void): Unsubscribe => {
|
|
292
299
|
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
300
|
|
|
298
|
-
return ()
|
|
299
|
-
unsubscribe()
|
|
300
|
-
// If no more subscribers remain, schedule GC
|
|
301
|
-
if (entry.subscriberCount === 0) this.#scheduleGC(storeId)
|
|
302
|
-
}
|
|
301
|
+
return entry.subscribe(listener)
|
|
303
302
|
}
|
|
304
303
|
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { makeInMemoryAdapter } from '@livestore/adapter-web'
|
|
2
|
+
import type { Store } from '@livestore/livestore'
|
|
3
|
+
import { StoreInternalsSymbol } from '@livestore/livestore'
|
|
4
|
+
import { type RenderResult, render, renderHook, waitFor } from '@testing-library/react'
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
+
import { schema } from '../../__tests__/fixture.tsx'
|
|
8
|
+
import { StoreRegistry } from './StoreRegistry.ts'
|
|
9
|
+
import { StoreRegistryProvider } from './StoreRegistryContext.tsx'
|
|
10
|
+
import { storeOptions } from './storeOptions.ts'
|
|
11
|
+
import type { CachedStoreOptions } from './types.ts'
|
|
12
|
+
import { useStore } from './useStore.ts'
|
|
13
|
+
|
|
14
|
+
describe('experimental useStore', () => {
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.clearAllTimers()
|
|
17
|
+
vi.useRealTimers()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('suspends when the store is loading', async () => {
|
|
21
|
+
const registry = new StoreRegistry()
|
|
22
|
+
const options = testStoreOptions()
|
|
23
|
+
|
|
24
|
+
const view = render(
|
|
25
|
+
<StoreRegistryProvider storeRegistry={registry}>
|
|
26
|
+
<React.Suspense fallback={<div data-testid="fallback" />}>
|
|
27
|
+
<StoreConsumer options={options} />
|
|
28
|
+
</React.Suspense>
|
|
29
|
+
</StoreRegistryProvider>,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
// Should show fallback while loading
|
|
33
|
+
expect(view.getByTestId('fallback')).toBeDefined()
|
|
34
|
+
|
|
35
|
+
// Wait for store to load and component to render
|
|
36
|
+
await waitForSuspenseResolved(view)
|
|
37
|
+
expect(view.getByTestId('ready')).toBeDefined()
|
|
38
|
+
|
|
39
|
+
cleanupWithPendingTimers(() => view.unmount())
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('does not re-suspend on subsequent renders when store is already loaded', async () => {
|
|
43
|
+
const registry = new StoreRegistry()
|
|
44
|
+
const options = testStoreOptions()
|
|
45
|
+
|
|
46
|
+
const Wrapper = ({ opts }: { opts: CachedStoreOptions<typeof schema> }) => (
|
|
47
|
+
<StoreRegistryProvider storeRegistry={registry}>
|
|
48
|
+
<React.Suspense fallback={<div data-testid="fallback" />}>
|
|
49
|
+
<StoreConsumer options={opts} />
|
|
50
|
+
</React.Suspense>
|
|
51
|
+
</StoreRegistryProvider>
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const view = render(<Wrapper opts={options} />)
|
|
55
|
+
|
|
56
|
+
// Wait for initial load
|
|
57
|
+
await waitForSuspenseResolved(view)
|
|
58
|
+
expect(view.getByTestId('ready')).toBeDefined()
|
|
59
|
+
|
|
60
|
+
// Rerender with new options object (but same storeId)
|
|
61
|
+
view.rerender(<Wrapper opts={{ ...options }} />)
|
|
62
|
+
|
|
63
|
+
// Should not show fallback
|
|
64
|
+
expect(view.queryByTestId('fallback')).toBeNull()
|
|
65
|
+
expect(view.getByTestId('ready')).toBeDefined()
|
|
66
|
+
|
|
67
|
+
cleanupWithPendingTimers(() => view.unmount())
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('throws when store loading fails', async () => {
|
|
71
|
+
const registry = new StoreRegistry()
|
|
72
|
+
const badOptions = testStoreOptions({
|
|
73
|
+
// @ts-expect-error - intentionally passing invalid adapter to trigger error
|
|
74
|
+
adapter: null,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// Pre-load the store to cache the error
|
|
78
|
+
await expect(registry.getOrLoad(badOptions)).rejects.toThrow()
|
|
79
|
+
|
|
80
|
+
// Now when useStore tries to get it, it should throw synchronously
|
|
81
|
+
expect(() =>
|
|
82
|
+
renderHook(() => useStore(badOptions), {
|
|
83
|
+
wrapper: makeProvider(registry),
|
|
84
|
+
}),
|
|
85
|
+
).toThrow()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it.each([
|
|
89
|
+
{ label: 'non-strict mode', strictMode: false },
|
|
90
|
+
{ label: 'strict mode', strictMode: true },
|
|
91
|
+
])('works in $label', async ({ strictMode }) => {
|
|
92
|
+
const registry = new StoreRegistry()
|
|
93
|
+
const options = testStoreOptions()
|
|
94
|
+
|
|
95
|
+
const { result, unmount } = renderHook(() => useStore(options), {
|
|
96
|
+
wrapper: makeProvider(registry, { suspense: true }),
|
|
97
|
+
reactStrictMode: strictMode,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// Wait for store to be ready
|
|
101
|
+
await waitForStoreReady(result)
|
|
102
|
+
expect(result.current[StoreInternalsSymbol].clientSession).toBeDefined()
|
|
103
|
+
|
|
104
|
+
cleanupWithPendingTimers(unmount)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('handles switching between different storeId values', async () => {
|
|
108
|
+
const registry = new StoreRegistry()
|
|
109
|
+
|
|
110
|
+
const optionsA = testStoreOptions({ storeId: 'store-a' })
|
|
111
|
+
const optionsB = testStoreOptions({ storeId: 'store-b' })
|
|
112
|
+
|
|
113
|
+
const { result, rerender, unmount } = renderHook((opts) => useStore(opts), {
|
|
114
|
+
initialProps: optionsA,
|
|
115
|
+
wrapper: makeProvider(registry, { suspense: true }),
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// Wait for first store to load
|
|
119
|
+
await waitForStoreReady(result)
|
|
120
|
+
const storeA = result.current
|
|
121
|
+
expect(storeA[StoreInternalsSymbol].clientSession).toBeDefined()
|
|
122
|
+
|
|
123
|
+
// Switch to different storeId
|
|
124
|
+
rerender(optionsB)
|
|
125
|
+
|
|
126
|
+
// Wait for second store to load and verify it's different from the first
|
|
127
|
+
await waitFor(() => {
|
|
128
|
+
expect(result.current).not.toBe(storeA)
|
|
129
|
+
expect(result.current?.[StoreInternalsSymbol].clientSession).toBeDefined()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const storeB = result.current
|
|
133
|
+
expect(storeB[StoreInternalsSymbol].clientSession).toBeDefined()
|
|
134
|
+
expect(storeB).not.toBe(storeA)
|
|
135
|
+
|
|
136
|
+
cleanupWithPendingTimers(unmount)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
const StoreConsumer = ({ options }: { options: CachedStoreOptions<any> }) => {
|
|
141
|
+
useStore(options)
|
|
142
|
+
return <div data-testid="ready" />
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const makeProvider =
|
|
146
|
+
(registry: StoreRegistry, { suspense = false }: { suspense?: boolean } = {}) =>
|
|
147
|
+
({ children }: { children: React.ReactNode }) => {
|
|
148
|
+
let content = <StoreRegistryProvider storeRegistry={registry}>{children}</StoreRegistryProvider>
|
|
149
|
+
|
|
150
|
+
if (suspense) {
|
|
151
|
+
content = <React.Suspense fallback={null}>{content}</React.Suspense>
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return content
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const testStoreOptions = (overrides: Partial<CachedStoreOptions<typeof schema>> = {}) =>
|
|
158
|
+
storeOptions({
|
|
159
|
+
storeId: 'test-store',
|
|
160
|
+
schema,
|
|
161
|
+
adapter: makeInMemoryAdapter(),
|
|
162
|
+
...overrides,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Cleans up after component unmount by synchronously executing any pending GC timers.
|
|
167
|
+
*
|
|
168
|
+
* When components using stores unmount, the StoreRegistry schedules garbage collection
|
|
169
|
+
* timers for inactive stores. Without this cleanup, those timers may fire during
|
|
170
|
+
* subsequent tests, causing cross-test pollution and flaky failures.
|
|
171
|
+
*
|
|
172
|
+
* This helper switches to fake timers, executes only the already-pending timers
|
|
173
|
+
* (allowing stores to shut down cleanly), then restores real timers for the next test.
|
|
174
|
+
*/
|
|
175
|
+
const cleanupWithPendingTimers = (cleanup: () => void): void => {
|
|
176
|
+
vi.useFakeTimers()
|
|
177
|
+
cleanup()
|
|
178
|
+
vi.runOnlyPendingTimers()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Waits for React Suspense fallback to resolve and the actual content to render.
|
|
183
|
+
*/
|
|
184
|
+
const waitForSuspenseResolved = async (view: RenderResult): Promise<void> => {
|
|
185
|
+
await waitFor(() => expect(view.queryByTestId('fallback')).toBeNull())
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Waits for a store to be fully loaded and ready to use.
|
|
190
|
+
* The store is considered ready when it has a defined clientSession.
|
|
191
|
+
*/
|
|
192
|
+
const waitForStoreReady = async (result: { current: Store<any> }): Promise<void> => {
|
|
193
|
+
await waitFor(() => {
|
|
194
|
+
expect(result.current).not.toBeNull()
|
|
195
|
+
expect(result.current[StoreInternalsSymbol].clientSession).toBeDefined()
|
|
196
|
+
})
|
|
197
|
+
}
|
|
@@ -20,11 +20,15 @@ export const useStore = <TSchema extends LiveStoreSchema>(
|
|
|
20
20
|
(onChange: () => void) => storeRegistry.subscribe(options.storeId, onChange),
|
|
21
21
|
[storeRegistry, options.storeId],
|
|
22
22
|
)
|
|
23
|
-
const getSnapshot = React.useCallback(() =>
|
|
23
|
+
const getSnapshot = React.useCallback(() => {
|
|
24
|
+
const storeOrPromise = storeRegistry.getOrLoad(options)
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
if (storeOrPromise instanceof Promise) throw storeOrPromise
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
return storeOrPromise
|
|
29
|
+
}, [storeRegistry, options])
|
|
30
|
+
|
|
31
|
+
const loadedStore = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
28
32
|
|
|
29
33
|
return withReactApi(loadedStore)
|
|
30
34
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** biome-ignore-all lint/a11y/useValidAriaRole: not needed for testing */
|
|
2
2
|
/** biome-ignore-all lint/a11y/noStaticElementInteractions: not needed for testing */
|
|
3
3
|
import * as LiveStore from '@livestore/livestore'
|
|
4
|
+
import { StoreInternalsSymbol } from '@livestore/livestore'
|
|
4
5
|
import { getAllSimplifiedRootSpans, getSimplifiedRootSpan } from '@livestore/livestore/internal/testing-utils'
|
|
5
6
|
import { Effect, ReadonlyRecord, Schema } from '@livestore/utils/effect'
|
|
6
7
|
import { Vitest } from '@livestore/utils-dev/node-vitest'
|
|
@@ -39,12 +40,12 @@ Vitest.describe('useClientDocument', () => {
|
|
|
39
40
|
expect(result.current.id).toBe('u1')
|
|
40
41
|
expect(result.current.state.username).toBe('')
|
|
41
42
|
expect(renderCount.val).toBe(1)
|
|
42
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
43
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
43
44
|
store.commit(tables.userInfo.set({ username: 'username_u2' }, 'u2'))
|
|
44
45
|
|
|
45
46
|
rerender('u2')
|
|
46
47
|
|
|
47
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
48
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
48
49
|
expect(result.current.id).toBe('u2')
|
|
49
50
|
expect(result.current.state.username).toBe('username_u2')
|
|
50
51
|
expect(renderCount.val).toBe(2)
|
package/src/useClientDocument.ts
CHANGED
|
@@ -111,7 +111,7 @@ export const useClientDocument: {
|
|
|
111
111
|
|
|
112
112
|
// console.debug('useClientDocument', tableName, id)
|
|
113
113
|
|
|
114
|
-
const idStr: string = id === SessionIdSymbol ? store.
|
|
114
|
+
const idStr: string = id === SessionIdSymbol ? store.sessionId : id
|
|
115
115
|
|
|
116
116
|
type QueryDef = LiveQueryDef<TTableDef['Value']>
|
|
117
117
|
const queryDef: QueryDef = React.useMemo(
|
package/src/useQuery.test.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** biome-ignore-all lint/a11y: test */
|
|
2
2
|
import * as LiveStore from '@livestore/livestore'
|
|
3
|
-
import { queryDb, signal } from '@livestore/livestore'
|
|
3
|
+
import { queryDb, StoreInternalsSymbol, signal } from '@livestore/livestore'
|
|
4
4
|
import { RG } from '@livestore/livestore/internal/testing-utils'
|
|
5
5
|
import { Effect, Schema } from '@livestore/utils/effect'
|
|
6
6
|
import { Vitest } from '@livestore/utils-dev/node-vitest'
|
|
@@ -38,14 +38,14 @@ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
|
|
|
38
38
|
|
|
39
39
|
expect(result.current.length).toBe(0)
|
|
40
40
|
expect(renderCount.val).toBe(1)
|
|
41
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
41
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
42
42
|
|
|
43
43
|
ReactTesting.act(() => store.commit(events.todoCreated({ id: 't1', text: 'buy milk', completed: false })))
|
|
44
44
|
|
|
45
45
|
expect(result.current.length).toBe(1)
|
|
46
46
|
expect(result.current[0]!.text).toBe('buy milk')
|
|
47
47
|
expect(renderCount.val).toBe(2)
|
|
48
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
48
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
49
49
|
}),
|
|
50
50
|
)
|
|
51
51
|
|
|
@@ -80,19 +80,25 @@ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
|
|
|
80
80
|
|
|
81
81
|
expect(result.current).toBe('buy milk')
|
|
82
82
|
expect(renderCount.val).toBe(1)
|
|
83
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
|
|
83
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
|
|
84
|
+
'1: after first render',
|
|
85
|
+
)
|
|
84
86
|
|
|
85
87
|
ReactTesting.act(() => store.commit(events.todoUpdated({ id: 't1', text: 'buy soy milk' })))
|
|
86
88
|
|
|
87
89
|
expect(result.current).toBe('buy soy milk')
|
|
88
90
|
expect(renderCount.val).toBe(2)
|
|
89
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
|
|
91
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
|
|
92
|
+
'2: after first commit',
|
|
93
|
+
)
|
|
90
94
|
|
|
91
95
|
rerender('t2')
|
|
92
96
|
|
|
93
97
|
expect(result.current).toBe('buy eggs')
|
|
94
98
|
expect(renderCount.val).toBe(3)
|
|
95
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
|
|
99
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
|
|
100
|
+
'3: after forced rerender',
|
|
101
|
+
)
|
|
96
102
|
}),
|
|
97
103
|
)
|
|
98
104
|
|
|
@@ -120,19 +126,19 @@ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
|
|
|
120
126
|
|
|
121
127
|
expect(result.current).toBe('buy milk')
|
|
122
128
|
expect(renderCount.val).toBe(1)
|
|
123
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
129
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
124
130
|
|
|
125
131
|
ReactTesting.act(() => store.commit(events.todoUpdated({ id: 't1', text: 'buy soy milk' })))
|
|
126
132
|
|
|
127
133
|
expect(result.current).toBe('buy soy milk')
|
|
128
134
|
expect(renderCount.val).toBe(2)
|
|
129
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
135
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
130
136
|
|
|
131
137
|
ReactTesting.act(() => store.setSignal(filter$, 't2'))
|
|
132
138
|
|
|
133
139
|
expect(result.current).toBe('buy eggs')
|
|
134
140
|
expect(renderCount.val).toBe(3)
|
|
135
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
141
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
136
142
|
}),
|
|
137
143
|
)
|
|
138
144
|
|
package/src/useQuery.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type Queryable,
|
|
7
7
|
queryDb,
|
|
8
8
|
type SignalDef,
|
|
9
|
+
StoreInternalsSymbol,
|
|
9
10
|
stackInfoToString,
|
|
10
11
|
} from '@livestore/livestore'
|
|
11
12
|
import type { LiveQueries } from '@livestore/livestore/internal'
|
|
@@ -122,17 +123,17 @@ export const useQueryRef = <TQueryable extends Queryable<any>>(
|
|
|
122
123
|
const { queryRcRef, span, otelContext } = useRcResource(
|
|
123
124
|
rcRefKey,
|
|
124
125
|
() => {
|
|
125
|
-
const span = store.otel.tracer.startSpan(
|
|
126
|
+
const span = store[StoreInternalsSymbol].otel.tracer.startSpan(
|
|
126
127
|
options?.otelSpanName ?? `LiveStore:useQuery:${resourceLabel}`,
|
|
127
128
|
{ attributes: { label: resourceLabel, firstStackInfo: JSON.stringify(stackInfo) } },
|
|
128
|
-
options?.otelContext ?? store.otel.queriesSpanContext,
|
|
129
|
+
options?.otelContext ?? store[StoreInternalsSymbol].otel.queriesSpanContext,
|
|
129
130
|
)
|
|
130
131
|
|
|
131
132
|
const otelContext = otel.trace.setSpan(otel.context.active(), span)
|
|
132
133
|
|
|
133
134
|
const queryRcRef =
|
|
134
135
|
normalized._tag === 'definition'
|
|
135
|
-
? normalized.def.make(store.reactivityGraph.context!, otelContext)
|
|
136
|
+
? normalized.def.make(store[StoreInternalsSymbol].reactivityGraph.context!, otelContext)
|
|
136
137
|
: ({
|
|
137
138
|
value: normalized.query$,
|
|
138
139
|
deref: () => {},
|