@livestore/react 0.4.0-dev.16 → 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.
Files changed (36) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/LiveStoreProvider.test.js +2 -2
  3. package/dist/LiveStoreProvider.test.js.map +1 -1
  4. package/dist/experimental/multi-store/StoreRegistry.d.ts.map +1 -1
  5. package/dist/experimental/multi-store/StoreRegistry.js +58 -58
  6. package/dist/experimental/multi-store/StoreRegistry.js.map +1 -1
  7. package/dist/experimental/multi-store/StoreRegistry.test.d.ts +2 -0
  8. package/dist/experimental/multi-store/StoreRegistry.test.d.ts.map +1 -0
  9. package/dist/experimental/multi-store/StoreRegistry.test.js +373 -0
  10. package/dist/experimental/multi-store/StoreRegistry.test.js.map +1 -0
  11. package/dist/experimental/multi-store/useStore.d.ts.map +1 -1
  12. package/dist/experimental/multi-store/useStore.js +7 -3
  13. package/dist/experimental/multi-store/useStore.js.map +1 -1
  14. package/dist/experimental/multi-store/useStore.test.d.ts +2 -0
  15. package/dist/experimental/multi-store/useStore.test.d.ts.map +1 -0
  16. package/dist/experimental/multi-store/useStore.test.js +144 -0
  17. package/dist/experimental/multi-store/useStore.test.js.map +1 -0
  18. package/dist/useClientDocument.js +1 -1
  19. package/dist/useClientDocument.js.map +1 -1
  20. package/dist/useClientDocument.test.js +3 -2
  21. package/dist/useClientDocument.test.js.map +1 -1
  22. package/dist/useQuery.d.ts.map +1 -1
  23. package/dist/useQuery.js +3 -3
  24. package/dist/useQuery.js.map +1 -1
  25. package/dist/useQuery.test.js +9 -9
  26. package/dist/useQuery.test.js.map +1 -1
  27. package/package.json +6 -6
  28. package/src/LiveStoreProvider.test.tsx +2 -2
  29. package/src/experimental/multi-store/StoreRegistry.test.ts +511 -0
  30. package/src/experimental/multi-store/StoreRegistry.ts +63 -64
  31. package/src/experimental/multi-store/useStore.test.tsx +197 -0
  32. package/src/experimental/multi-store/useStore.ts +7 -3
  33. package/src/useClientDocument.test.tsx +3 -2
  34. package/src/useClientDocument.ts +1 -1
  35. package/src/useQuery.test.tsx +15 -9
  36. 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
- * Minimal cache entry that tracks store state and subscribers.
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
- * The number of active subscribers for this entry.
27
- */
28
- get subscriberCount() {
29
- return this.#subscribers.size
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
- this.#reset()
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
- remove = (storeId: StoreId): void => {
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 entry = this.#cache.ensure<TSchema>(optionsWithDefaults.storeId)
260
-
261
- const storeOrPromise = entry.getOrLoad(optionsWithDefaults)
262
-
263
- if (storeOrPromise instanceof Promise) {
264
- return storeOrPromise.finally(() => {
265
- // If no subscribers remain after load settles, schedule GC
266
- if (entry.subscriberCount === 0) this.#scheduleGC(optionsWithDefaults.storeId)
267
- })
268
- }
275
+ const storeEntry = this.#cache.ensure<TSchema>(optionsWithDefaults.storeId)
269
276
 
270
- return storeOrPromise
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(() => storeRegistry.getOrLoad(options), [storeRegistry, options])
23
+ const getSnapshot = React.useCallback(() => {
24
+ const storeOrPromise = storeRegistry.getOrLoad(options)
24
25
 
25
- const storeOrPromise = React.useSyncExternalStore(subscribe, getSnapshot)
26
+ if (storeOrPromise instanceof Promise) throw storeOrPromise
26
27
 
27
- const loadedStore = storeOrPromise instanceof Promise ? React.use(storeOrPromise) : storeOrPromise
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)
@@ -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.clientSession.sessionId : id
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(
@@ -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('1: after first render')
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('2: after first commit')
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('3: after forced rerender')
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: () => {},