@livestore/react 0.4.0-dev.9 → 0.4.0

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 (85) hide show
  1. package/README.md +1 -1
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/StoreRegistryContext.d.ts +56 -0
  4. package/dist/StoreRegistryContext.d.ts.map +1 -0
  5. package/dist/StoreRegistryContext.js +61 -0
  6. package/dist/StoreRegistryContext.js.map +1 -0
  7. package/dist/__tests__/fixture.d.ts +12 -283
  8. package/dist/__tests__/fixture.d.ts.map +1 -1
  9. package/dist/__tests__/fixture.js +12 -86
  10. package/dist/__tests__/fixture.js.map +1 -1
  11. package/dist/experimental/components/LiveList.d.ts +4 -2
  12. package/dist/experimental/components/LiveList.d.ts.map +1 -1
  13. package/dist/experimental/components/LiveList.js +10 -8
  14. package/dist/experimental/components/LiveList.js.map +1 -1
  15. package/dist/mod.d.ts +8 -5
  16. package/dist/mod.d.ts.map +1 -1
  17. package/dist/mod.js +6 -4
  18. package/dist/mod.js.map +1 -1
  19. package/dist/useClientDocument.d.ts +12 -4
  20. package/dist/useClientDocument.d.ts.map +1 -1
  21. package/dist/useClientDocument.js +4 -18
  22. package/dist/useClientDocument.js.map +1 -1
  23. package/dist/useClientDocument.test.js +21 -9
  24. package/dist/useClientDocument.test.js.map +1 -1
  25. package/dist/useQuery.d.ts +29 -8
  26. package/dist/useQuery.d.ts.map +1 -1
  27. package/dist/useQuery.js +43 -70
  28. package/dist/useQuery.js.map +1 -1
  29. package/dist/useQuery.test.js +57 -13
  30. package/dist/useQuery.test.js.map +1 -1
  31. package/dist/useRcResource.d.ts +1 -1
  32. package/dist/useRcResource.d.ts.map +1 -1
  33. package/dist/useRcResource.js +41 -34
  34. package/dist/useRcResource.js.map +1 -1
  35. package/dist/useRcResource.test.js +72 -50
  36. package/dist/useRcResource.test.js.map +1 -1
  37. package/dist/useStore.d.ts +74 -7
  38. package/dist/useStore.d.ts.map +1 -1
  39. package/dist/useStore.js +82 -16
  40. package/dist/useStore.js.map +1 -1
  41. package/dist/useStore.test.d.ts +2 -0
  42. package/dist/useStore.test.d.ts.map +1 -0
  43. package/dist/useStore.test.js +241 -0
  44. package/dist/useStore.test.js.map +1 -0
  45. package/dist/useSyncStatus.d.ts +22 -0
  46. package/dist/useSyncStatus.d.ts.map +1 -0
  47. package/dist/useSyncStatus.js +28 -0
  48. package/dist/useSyncStatus.js.map +1 -0
  49. package/package.json +69 -26
  50. package/src/StoreRegistryContext.tsx +70 -0
  51. package/src/__snapshots__/useClientDocument.test.tsx.snap +112 -78
  52. package/src/__snapshots__/useQuery.test.tsx.snap +12 -12
  53. package/src/__tests__/fixture.tsx +29 -133
  54. package/src/experimental/components/LiveList.tsx +23 -10
  55. package/src/mod.ts +8 -12
  56. package/src/useClientDocument.test.tsx +90 -79
  57. package/src/useClientDocument.ts +19 -38
  58. package/src/useQuery.test.tsx +99 -13
  59. package/src/useQuery.ts +74 -89
  60. package/src/useRcResource.test.tsx +115 -59
  61. package/src/useRcResource.ts +51 -37
  62. package/src/useStore.test.tsx +347 -0
  63. package/src/useStore.ts +115 -22
  64. package/src/useSyncStatus.ts +34 -0
  65. package/dist/LiveStoreContext.d.ts +0 -13
  66. package/dist/LiveStoreContext.d.ts.map +0 -1
  67. package/dist/LiveStoreContext.js +0 -3
  68. package/dist/LiveStoreContext.js.map +0 -1
  69. package/dist/LiveStoreProvider.d.ts +0 -65
  70. package/dist/LiveStoreProvider.d.ts.map +0 -1
  71. package/dist/LiveStoreProvider.js +0 -221
  72. package/dist/LiveStoreProvider.js.map +0 -1
  73. package/dist/LiveStoreProvider.test.d.ts +0 -2
  74. package/dist/LiveStoreProvider.test.d.ts.map +0 -1
  75. package/dist/LiveStoreProvider.test.js +0 -117
  76. package/dist/LiveStoreProvider.test.js.map +0 -1
  77. package/dist/utils/stack-info.d.ts +0 -4
  78. package/dist/utils/stack-info.d.ts.map +0 -1
  79. package/dist/utils/stack-info.js +0 -10
  80. package/dist/utils/stack-info.js.map +0 -1
  81. package/src/LiveStoreContext.ts +0 -14
  82. package/src/LiveStoreProvider.test.tsx +0 -248
  83. package/src/LiveStoreProvider.tsx +0 -413
  84. package/src/ambient.d.ts +0 -1
  85. package/src/utils/stack-info.ts +0 -13
@@ -25,10 +25,10 @@ import * as React from 'react'
25
25
  * - Upon component unmount, the reference count is decremented, leading to disposal (via the `dispose` function)
26
26
  * if the reference count drops to zero. An unmount is either detected via React's `useEffect` callback or
27
27
  * in the useMemo hook when the key changes.
28
- *
28
+ *
29
29
  * Why this is needed in LiveStore:
30
30
  * Let's first take a look at the "trivial implementation":
31
- *
31
+ *
32
32
  * ```ts
33
33
  * const useSimpleResource = <T>(create: () => T, dispose: (resource: T) => void) => {
34
34
  * const val = React.useMemo(() => create(), [create])
@@ -42,7 +42,7 @@ import * as React from 'react'
42
42
  * return val
43
43
  * }
44
44
  * ```
45
- *
45
+ *
46
46
  * LiveStore uses this hook to create LiveQuery instances which are stateful and must not be leaked.
47
47
  * The simple implementation above would leak the LiveQuery instance if the component is unmounted or props change.
48
48
  *
@@ -72,87 +72,87 @@ import * as React from 'react'
72
72
  * @returns The stateful entity corresponding to the provided key.
73
73
  */
74
74
  export const useRcResource = <T>(
75
+ scope: object,
75
76
  key: string,
76
77
  create: () => T,
77
78
  dispose: (resource: NoInfer<T>) => void,
78
79
  _options?: { debugPrint?: (resource: NoInfer<T>) => ReadonlyArray<any> },
79
80
  ): T => {
80
81
  const keyRef = React.useRef<string | undefined>(undefined)
82
+ const scopeRef = React.useRef<object | undefined>(undefined)
81
83
  const didDisposeInMemo = React.useRef(false)
84
+ const createRef = React.useRef(create)
85
+ const disposeRef = React.useRef(dispose)
82
86
 
83
- // biome-ignore lint/correctness/useExhaustiveDependencies: Dependency is deliberately limited to `key` to avoid unintended re-creations.
87
+ createRef.current = create
88
+ disposeRef.current = dispose
89
+
90
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Dependencies are deliberately limited to `scope` and `key` to avoid unintended re-creations.
84
91
  const resource = React.useMemo(() => {
85
- // console.debug('useMemo', key)
86
- if (didDisposeInMemo.current) {
87
- // console.debug('useMemo', key, 'skip')
88
- const cachedItem = cache.get(key)
92
+ const bucket = getBucket(scope)
93
+
94
+ if (didDisposeInMemo.current === true) {
95
+ const cachedItem = bucket.get(key)
89
96
  if (cachedItem !== undefined && cachedItem._tag === 'active') {
90
97
  return cachedItem.resource
91
98
  }
92
99
  }
93
100
 
94
- // Check if the key has changed (or is undefined)
95
- if (keyRef.current !== undefined && keyRef.current !== key) {
96
- // If the key has changed, decrement the reference on the previous key
101
+ // Check if the (scope, key) pair has changed (or is undefined)
102
+ if (keyRef.current !== undefined && (keyRef.current !== key || scopeRef.current !== scope)) {
97
103
  const previousKey = keyRef.current
98
- const cachedItemForPreviousKey = cache.get(previousKey)
104
+ // scopeRef.current is set together with keyRef.current below, so it's defined here.
105
+ const previousBucket = getBucket(scopeRef.current!)
106
+ const cachedItemForPreviousKey = previousBucket.get(previousKey)
99
107
  if (cachedItemForPreviousKey !== undefined && cachedItemForPreviousKey._tag === 'active') {
100
- // previousKeyRef.current = previousKey
101
108
  cachedItemForPreviousKey.rc--
102
109
 
103
- // console.debug('useMemo', key, 'rc--', previousKey, cachedItemForPreviousKey.rc)
104
-
105
110
  if (cachedItemForPreviousKey.rc === 0) {
106
111
  // Clean up the stateful resource if no longer referenced
107
- dispose(cachedItemForPreviousKey.resource)
108
- cache.set(previousKey, { _tag: 'destroyed' })
112
+ disposeRef.current(cachedItemForPreviousKey.resource)
113
+ previousBucket.set(previousKey, { _tag: 'destroyed' })
109
114
  didDisposeInMemo.current = true
110
115
  }
111
116
  }
112
117
  }
113
118
 
114
- const cachedItem = cache.get(key)
119
+ const cachedItem = bucket.get(key)
115
120
  if (cachedItem !== undefined && cachedItem._tag === 'active') {
116
121
  // In React Strict Mode, the `useMemo` hook is called multiple times,
117
122
  // so we only increment the reference from the first call for this component.
118
123
  cachedItem.rc++
119
- // console.debug('rc++', cachedItem.rc, ...(_options?.debugPrint?.(cachedItem.resource) ?? []))
120
-
121
124
  return cachedItem.resource
122
125
  }
123
126
 
124
127
  // Create a new stateful resource if not cached
125
- const resource = create()
126
- cache.set(key, { _tag: 'active', rc: 1, resource })
128
+ const resource = createRef.current()
129
+ bucket.set(key, { _tag: 'active', rc: 1, resource })
127
130
  return resource
128
- }, [key])
131
+ }, [scope, key])
129
132
 
130
133
  // biome-ignore lint/correctness/useExhaustiveDependencies: We assume the `dispose` function is stable and won't change across renders
131
134
  React.useEffect(() => {
132
135
  return () => {
133
- if (didDisposeInMemo.current) {
134
- // console.debug('unmount', keyRef.current, 'skip')
136
+ if (didDisposeInMemo.current === true) {
135
137
  didDisposeInMemo.current = false
136
138
  return
137
139
  }
138
140
 
139
- // console.debug('unmount', keyRef.current)
140
- const cachedItem = cache.get(key)
141
- // If the stateful resource is already cleaned up, do nothing.
141
+ const bucket = getBucket(scope)
142
+ const cachedItem = bucket.get(key)
142
143
  if (cachedItem === undefined || cachedItem._tag === 'destroyed') return
143
144
 
144
145
  cachedItem.rc--
145
146
 
146
- // console.debug('rc--', cachedItem.rc, ...(_options?.debugPrint?.(cachedItem.resource) ?? []))
147
-
148
147
  if (cachedItem.rc === 0) {
149
- dispose(cachedItem.resource)
150
- cache.delete(key)
148
+ disposeRef.current(cachedItem.resource)
149
+ bucket.delete(key)
151
150
  }
152
151
  }
153
- }, [key])
152
+ }, [scope, key])
154
153
 
155
154
  keyRef.current = key
155
+ scopeRef.current = scope
156
156
 
157
157
  return resource
158
158
  }
@@ -161,8 +161,7 @@ export const useRcResource = <T>(
161
161
  // we are using this cache to avoid starting multiple queries/spans for the same component.
162
162
  // This is somewhat against some recommended React best practices, but it should be fine in our case below.
163
163
  // Please definitely open an issue if you see or run into any problems with this approach!
164
- const cache = new Map<
165
- string,
164
+ type Entry =
166
165
  | {
167
166
  _tag: 'active'
168
167
  rc: number
@@ -171,8 +170,23 @@ const cache = new Map<
171
170
  | {
172
171
  _tag: 'destroyed'
173
172
  }
174
- >()
173
+
174
+ type Bucket = Map<string, Entry>
175
+
176
+ // Per-scope buckets. Keying by the scope object (e.g. a Store instance) ensures that
177
+ // when the scope is replaced (store dispose/recreate), the new scope gets a fresh bucket
178
+ // and stale entries from the disposed scope become GC-eligible. See issue #1186.
179
+ let scopedBuckets = new WeakMap<object, Bucket>()
180
+
181
+ const getBucket = (scope: object): Bucket => {
182
+ let bucket = scopedBuckets.get(scope)
183
+ if (bucket === undefined) {
184
+ bucket = new Map()
185
+ scopedBuckets.set(scope, bucket)
186
+ }
187
+ return bucket
188
+ }
175
189
 
176
190
  export const __resetUseRcResourceCache = () => {
177
- cache.clear()
191
+ scopedBuckets = new WeakMap()
178
192
  }
@@ -0,0 +1,347 @@
1
+ import { makeInMemoryAdapter } from '@livestore/adapter-web'
2
+ import {
3
+ type RegistryStoreOptions,
4
+ type Store,
5
+ StoreInternalsSymbol,
6
+ StoreRegistry,
7
+ storeOptions,
8
+ } from '@livestore/livestore'
9
+ import { shouldNeverHappen } from '@livestore/utils'
10
+ import { act, type RenderHookResult, type RenderResult, fireEvent, render, renderHook, waitFor } from '@testing-library/react'
11
+ import * as React from 'react'
12
+ import { describe, expect, it, vi } from 'vitest'
13
+
14
+ import { schema } from './__tests__/fixture.tsx'
15
+ import { StoreRegistryProvider } from './StoreRegistryContext.tsx'
16
+ import { useStore } from './useStore.ts'
17
+
18
+ describe('experimental useStore', () => {
19
+ it('should return the same promise instance for concurrent getOrLoadStore calls', async () => {
20
+ const storeRegistry = new StoreRegistry()
21
+ const options = testStoreOptions()
22
+
23
+ // Make two concurrent calls during loading
24
+ const firstStore = storeRegistry.getOrLoadPromise(options)
25
+ const secondStore = storeRegistry.getOrLoadPromise(options)
26
+
27
+ // Both should be promises (store is loading)
28
+ expect(firstStore).toBeInstanceOf(Promise)
29
+ expect(secondStore).toBeInstanceOf(Promise)
30
+
31
+ // EXPECTED BEHAVIOR: Same promise instance for React.use() compatibility
32
+ // ACTUAL BEHAVIOR: Different promise instances (Effect.runPromise creates new wrapper)
33
+ expect(firstStore).toBe(secondStore)
34
+
35
+ // Cleanup
36
+ await firstStore
37
+ await cleanupAfterUnmount(() => {})
38
+ })
39
+
40
+ it('works with Suspense boundary', async () => {
41
+ const storeRegistry = new StoreRegistry()
42
+ const options = testStoreOptions()
43
+
44
+ let view: RenderResult | undefined
45
+ await act(async () => {
46
+ view = render(
47
+ <StoreRegistryProvider storeRegistry={storeRegistry}>
48
+ <React.Suspense fallback={makeSuspenseFallback()}>
49
+ <StoreConsumer options={options} />
50
+ </React.Suspense>
51
+ </StoreRegistryProvider>,
52
+ )
53
+ })
54
+ const renderedView = view ?? shouldNeverHappen('render failed')
55
+
56
+ // After loading completes, should show the actual content
57
+ await waitForSuspenseResolved(renderedView)
58
+ expect(renderedView.getByTestId('ready')).toBeDefined()
59
+
60
+ await cleanupAfterUnmount(() => renderedView.unmount())
61
+ })
62
+
63
+ it('does not re-suspend on subsequent renders when store is already loaded', async () => {
64
+ const storeRegistry = new StoreRegistry()
65
+ const options = testStoreOptions()
66
+ const Wrapper = ({ opts }: { opts: RegistryStoreOptions<typeof schema> }) => (
67
+ <StoreRegistryProvider storeRegistry={storeRegistry}>
68
+ <React.Suspense fallback={makeSuspenseFallback()}>
69
+ <StoreConsumer options={opts} />
70
+ </React.Suspense>
71
+ </StoreRegistryProvider>
72
+ )
73
+
74
+ let view: RenderResult | undefined
75
+ await act(async () => {
76
+ view = render(<Wrapper opts={options} />)
77
+ })
78
+ const renderedView = view ?? shouldNeverHappen('render failed')
79
+
80
+ // Wait for initial load
81
+ await waitForSuspenseResolved(renderedView)
82
+ expect(renderedView.getByTestId('ready')).toBeDefined()
83
+
84
+ // Rerender with new options object (but same storeId)
85
+ const rerenderOptions = cloneStoreOptions(options)
86
+ await act(async () => {
87
+ renderedView.rerender(<Wrapper opts={rerenderOptions} />)
88
+ })
89
+
90
+ // Should not show fallback
91
+ expect(renderedView.queryByTestId('fallback')).toBeNull()
92
+ expect(renderedView.getByTestId('ready')).toBeDefined()
93
+
94
+ await cleanupAfterUnmount(() => renderedView.unmount())
95
+ })
96
+
97
+ it('throws when store loading fails', async () => {
98
+ const storeRegistry = new StoreRegistry()
99
+ const badOptions = testStoreOptions({
100
+ // @ts-expect-error - intentionally passing invalid adapter to trigger error
101
+ adapter: null,
102
+ })
103
+
104
+ // Pre-load the store to cache the error (error happens synchronously)
105
+ expect(() => storeRegistry.getOrLoadPromise(badOptions)).toThrow()
106
+
107
+ // Now when useStore tries to get it, it should throw synchronously
108
+ expect(() =>
109
+ renderHook(() => useStore(badOptions), {
110
+ wrapper: makeProvider(storeRegistry),
111
+ }),
112
+ ).toThrow()
113
+ })
114
+
115
+ it.each([
116
+ { label: 'non-strict mode', strictMode: false },
117
+ { label: 'strict mode', strictMode: true },
118
+ ])('works in $label', async ({ strictMode }) => {
119
+ const storeRegistry = new StoreRegistry()
120
+ const options = testStoreOptions()
121
+
122
+ let hook: RenderHookResult<Store<typeof schema>, RegistryStoreOptions<typeof schema>> | undefined
123
+ await act(async () => {
124
+ hook = renderHook(() => useStore(options), {
125
+ wrapper: makeProvider(storeRegistry, { suspense: true }),
126
+ reactStrictMode: strictMode,
127
+ })
128
+ })
129
+ const { result, unmount } = hook ?? shouldNeverHappen('renderHook failed')
130
+
131
+ // Wait for store to be ready
132
+ await waitForStoreReady(result)
133
+ expect(result.current[StoreInternalsSymbol].clientSession).toBeDefined()
134
+
135
+ await cleanupAfterUnmount(unmount)
136
+ })
137
+
138
+ it('handles switching between different storeId values', async () => {
139
+ const storeRegistry = new StoreRegistry()
140
+
141
+ const optionsA = testStoreOptions({ storeId: 'store-a' })
142
+ const optionsB = testStoreOptions({ storeId: 'store-b' })
143
+
144
+ let hook: RenderHookResult<Store<typeof schema>, RegistryStoreOptions<typeof schema>> | undefined
145
+ await act(async () => {
146
+ hook = renderHook((opts) => useStore(opts), {
147
+ initialProps: optionsA,
148
+ wrapper: makeProvider(storeRegistry, { suspense: true }),
149
+ })
150
+ })
151
+ const { result, rerender, unmount } = hook ?? shouldNeverHappen('renderHook failed')
152
+
153
+ // Wait for first store to load
154
+ await waitForStoreReady(result)
155
+ const storeA = result.current
156
+ expect(storeA[StoreInternalsSymbol].clientSession).toBeDefined()
157
+
158
+ // Switch to different storeId
159
+ await act(async () => {
160
+ rerender(optionsB)
161
+ })
162
+
163
+ // Wait for second store to load and verify it's different from the first
164
+ await waitFor(() => {
165
+ expect(result.current).not.toBe(storeA)
166
+ expect(result.current?.[StoreInternalsSymbol].clientSession).toBeDefined()
167
+ })
168
+
169
+ const storeB = result.current
170
+ expect(storeB[StoreInternalsSymbol].clientSession).toBeDefined()
171
+ expect(storeB).not.toBe(storeA)
172
+
173
+ await cleanupAfterUnmount(unmount)
174
+ })
175
+
176
+ it('does not block useActionState transitions from committing', async () => {
177
+ const storeRegistry = new StoreRegistry()
178
+ const options = testStoreOptions()
179
+ const getOrLoadSpy = vi.spyOn(storeRegistry, 'getOrLoadPromise')
180
+
181
+ let view: RenderResult | undefined
182
+ await act(async () => {
183
+ view = render(
184
+ <StoreRegistryProvider storeRegistry={storeRegistry}>
185
+ <React.Suspense fallback={makeSuspenseFallback()}>
186
+ <StoreWithActionState options={options} />
187
+ </React.Suspense>
188
+ </StoreRegistryProvider>,
189
+ )
190
+ })
191
+ const renderedView = view ?? shouldNeverHappen('render failed')
192
+
193
+ // Wait for store to load
194
+ await waitForSuspenseResolved(renderedView)
195
+ expect(renderedView.getByTestId('state').textContent).toBe('none')
196
+ expect(renderedView.getByTestId('pending').textContent).toBe('false')
197
+
198
+ // After store is loaded, clear spy to only track calls during the transition render
199
+ getOrLoadSpy.mockClear()
200
+
201
+ // Trigger a useActionState transition
202
+ await act(async () => {
203
+ fireEvent.click(renderedView.getByTestId('submit'))
204
+ })
205
+
206
+ // getOrLoadPromise must be called on each render (not cached via useMemo).
207
+ // When the initial Promise is cached, React.use() is called with a resolved Promise
208
+ // on every subsequent render, which blocks React transitions (e.g. useActionState)
209
+ // from ever committing in browser environments.
210
+ expect(getOrLoadSpy).toHaveBeenCalled()
211
+
212
+ // The transition should commit: state updates and isPending returns to false
213
+ await waitFor(() => {
214
+ expect(renderedView.getByTestId('state').textContent).toBe('updated')
215
+ expect(renderedView.getByTestId('pending').textContent).toBe('false')
216
+ })
217
+
218
+ getOrLoadSpy.mockRestore()
219
+ await cleanupAfterUnmount(() => renderedView.unmount())
220
+ })
221
+
222
+ // useStore doesn't handle unusedCacheTime=0 correctly because retain is called in useEffect (after render)
223
+ // See https://github.com/livestorejs/livestore/issues/916
224
+ it.skip('should load store with unusedCacheTime set to 0', async () => {
225
+ const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime: 0 } })
226
+ const options = testStoreOptions({ unusedCacheTime: 0 })
227
+ const StoreConsumerWithVerification = ({ opts }: { opts: RegistryStoreOptions<typeof schema> }) => {
228
+ const store = useStore(opts)
229
+ // Verify store is usable - access internals to confirm it's not disposed
230
+ const clientSession = store[StoreInternalsSymbol].clientSession
231
+ return <div data-testid="ready" data-has-session={String(clientSession !== undefined)} />
232
+ }
233
+
234
+ let view: RenderResult | undefined
235
+ await act(async () => {
236
+ view = render(
237
+ <StoreRegistryProvider storeRegistry={storeRegistry}>
238
+ <React.Suspense fallback={makeSuspenseFallback()}>
239
+ <StoreConsumerWithVerification opts={options} />
240
+ </React.Suspense>
241
+ </StoreRegistryProvider>,
242
+ )
243
+ })
244
+ const renderedView = view ?? shouldNeverHappen('render failed')
245
+
246
+ await waitForSuspenseResolved(renderedView)
247
+
248
+ // Store should be usable while component is mounted
249
+ const readyElement = renderedView.getByTestId('ready')
250
+ expect(readyElement.getAttribute('data-has-session')).toBe('true')
251
+
252
+ // Allow some time to pass to ensure store isn't prematurely disposed
253
+ await new Promise((resolve) => setTimeout(resolve, 50))
254
+
255
+ // Store should still be usable after waiting
256
+ expect(readyElement.getAttribute('data-has-session')).toBe('true')
257
+
258
+ await cleanupAfterUnmount(() => renderedView.unmount())
259
+ })
260
+ })
261
+
262
+ const StoreConsumer = ({ options }: { options: RegistryStoreOptions<any> }) => {
263
+ useStore(options)
264
+ return <div data-testid="ready" />
265
+ }
266
+
267
+ /** Component that combines useStore with useActionState to test transition compatibility. */
268
+ const StoreWithActionState = ({ options }: { options: RegistryStoreOptions<any> }) => {
269
+ useStore(options)
270
+
271
+ const [state, dispatch, isPending] = React.useActionState(
272
+ (_prev: string | undefined, value: string): string => value,
273
+ undefined,
274
+ )
275
+
276
+ return (
277
+ <div>
278
+ <button
279
+ data-testid="submit"
280
+ // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop -- test component
281
+ onClick={() => React.startTransition(() => dispatch('updated'))}
282
+ >
283
+ Submit
284
+ </button>
285
+ <div data-testid="state">{state ?? 'none'}</div>
286
+ <div data-testid="pending">{String(isPending)}</div>
287
+ </div>
288
+ )
289
+ }
290
+
291
+ const makeSuspenseFallback = () => React.createElement('div', { 'data-testid': 'fallback' })
292
+
293
+ const cloneStoreOptions = <TOptions extends object>(options: TOptions): TOptions => {
294
+ return { ...options }
295
+ }
296
+
297
+ const makeProvider =
298
+ (storeRegistry: StoreRegistry, { suspense = false }: { suspense?: boolean } = {}) =>
299
+ ({ children }: { children: React.ReactNode }) => {
300
+ let content = <StoreRegistryProvider storeRegistry={storeRegistry}>{children}</StoreRegistryProvider>
301
+
302
+ if (suspense !== undefined) {
303
+ content = <React.Suspense fallback={null}>{content}</React.Suspense>
304
+ }
305
+
306
+ return content
307
+ }
308
+
309
+ let testStoreCounter = 0
310
+
311
+ const testStoreOptions = (overrides: Partial<RegistryStoreOptions<typeof schema>> = {}) =>
312
+ storeOptions({
313
+ storeId: overrides.storeId ?? `test-store-${testStoreCounter++}`,
314
+ schema,
315
+ adapter: makeInMemoryAdapter(),
316
+ ...overrides,
317
+ })
318
+
319
+ /**
320
+ * Cleans up after component unmount and waits for pending operations to settle.
321
+ *
322
+ * When components using stores unmount, the StoreRegistry schedules garbage collection
323
+ * timers for inactive stores. This helper waits for those timers to complete naturally.
324
+ */
325
+ const cleanupAfterUnmount = async (cleanup: () => void): Promise<void> => {
326
+ cleanup()
327
+ // Allow any pending microtasks/timers to settle
328
+ await new Promise((resolve) => setTimeout(resolve, 100))
329
+ }
330
+
331
+ /**
332
+ * Waits for React Suspense fallback to resolve and the actual content to render.
333
+ */
334
+ const waitForSuspenseResolved = async (view: RenderResult): Promise<void> => {
335
+ await waitFor(() => expect(view.queryByTestId('fallback')).toBeNull())
336
+ }
337
+
338
+ /**
339
+ * Waits for a store to be fully loaded and ready to use.
340
+ * The store is considered ready when it has a defined clientSession.
341
+ */
342
+ const waitForStoreReady = async (result: { current: Store<any> }): Promise<void> => {
343
+ await waitFor(() => {
344
+ expect(result.current).not.toBeNull()
345
+ expect(result.current[StoreInternalsSymbol].clientSession).toBeDefined()
346
+ })
347
+ }
package/src/useStore.ts CHANGED
@@ -1,36 +1,129 @@
1
- import type { Store } from '@livestore/livestore'
2
1
  import React from 'react'
3
2
 
4
- import type { ReactApi } from './LiveStoreContext.ts'
5
- import { LiveStoreContext } from './LiveStoreContext.ts'
3
+ import type { LiveStoreSchema } from '@livestore/common/schema'
4
+ import type { RegistryStoreOptions, Store, SyncStatus } from '@livestore/livestore'
5
+ import type { Schema } from '@livestore/utils/effect'
6
+
7
+ import { useStoreRegistry } from './StoreRegistryContext.tsx'
6
8
  import { useClientDocument } from './useClientDocument.ts'
7
9
  import { useQuery } from './useQuery.ts'
10
+ import { useSyncStatus } from './useSyncStatus.ts'
8
11
 
9
- export const withReactApi = (store: Store): Store & ReactApi => {
10
- // @ts-expect-error TODO properly implement this
12
+ /**
13
+ * Returns a store instance augmented with hooks (`store.useQuery()` and `store.useClientDocument()`) for reactive queries.
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * function Issue() {
18
+ * // Suspends until loaded or returns immediately if already loaded
19
+ * const issueStore = useStore(issueStoreOptions('abc123'))
20
+ * const [issue] = issueStore.useQuery(queryDb(tables.issue.select()))
21
+ *
22
+ * const toggleStatus = () =>
23
+ * issueStore.commit(
24
+ * issueEvents.issueStatusChanged({
25
+ * id: issue.id,
26
+ * status: issue.status === 'done' ? 'todo' : 'done',
27
+ * }),
28
+ * )
29
+ *
30
+ * const preloadParentIssue = (issueId: string) =>
31
+ * storeRegistry.preload({
32
+ * ...issueStoreOptions(issueId),
33
+ * unusedCacheTime: 10_000,
34
+ * })
35
+ *
36
+ * return (
37
+ * <>
38
+ * <h2>{issue.title}</h2>
39
+ * <button onClick={() => toggleStatus()}>Toggle Status</button>
40
+ * <button onMouseEnter={() => preloadParentIssue(issue.parentIssueId)}>Open Parent Issue</button>
41
+ * </>
42
+ * )
43
+ * }
44
+ * ```
45
+ *
46
+ * @remarks
47
+ * - Suspends until the store is loaded.
48
+ * - Store is cached by its `storeId` in the `StoreRegistry`. Multiple calls with the same `storeId` return the same store instance.
49
+ * - Store is cached as long as it's being used, and after `unusedCacheTime` expires (default `60_000` ms in browser, `Infinity` in non-browser)
50
+ * - Default store options can be configured in `StoreRegistry` constructor.
51
+ * - Store options are only applied when the store is loaded. Subsequent calls with different options will not affect the store if it's already loaded and cached in the registry.
52
+ *
53
+ * @typeParam TSchema - The schema type for the store
54
+ * @returns The loaded store instance augmented with React hooks
55
+ * @throws unknown - store loading error or if called outside `<StoreRegistryProvider>`
56
+ */
57
+ export const useStore = <
58
+ TSchema extends LiveStoreSchema,
59
+ TContext = {},
60
+ TSyncPayloadSchema extends Schema.Schema<any> = typeof Schema.JsonValue,
61
+ >(
62
+ options: RegistryStoreOptions<TSchema, TContext, TSyncPayloadSchema>,
63
+ ): Store<TSchema, TContext> & ReactApi => {
64
+ const storeRegistry = useStoreRegistry()
11
65
 
12
- store.useQuery = (queryDef) => useQuery(queryDef, { store })
13
- // @ts-expect-error TODO properly implement this
66
+ // Called on every render (intentionally not memoized). For already-loaded stores this returns
67
+ // the Store synchronously, so React.use() is skipped entirely. Caching the initial Promise via
68
+ // useMemo would cause React.use() to be called with a resolved Promise on subsequent renders,
69
+ // which blocks React transitions from committing.
70
+ const storeOrPromise = storeRegistry.getOrLoadPromise(options)
14
71
 
15
- store.useClientDocument = (table, idOrOptions, options) => useClientDocument(table, idOrOptions, options, { store })
16
- return store as Store & ReactApi
72
+ const store = storeOrPromise instanceof Promise ? React.use(storeOrPromise) : storeOrPromise
73
+
74
+ // NOTE: retain() must be declared AFTER the React.use() call above. When React.use() suspends
75
+ // the component, any hooks declared before it get committed while hooks after the suspension
76
+ // point (including those in the caller) don't. On re-render when the store resolves synchronously,
77
+ // those late hooks appear for the first time, causing a hook-order violation in strict mode.
78
+ // By placing useEffect after React.use(), no effect hooks are committed during suspension,
79
+ // so React treats all effects as fresh mounts on the first successful render.
80
+ //
81
+ // retain() is called in useEffect (after render), while getOrLoadPromise() is called during
82
+ // render. This creates a timing gap where with very short unusedCacheTime values (e.g., 0),
83
+ // the store could theoretically be disposed before the effect fires. In practice, this is not
84
+ // an issue with the default 60s cache time, but it becomes an issue when `unusedCacheTime` is
85
+ // configured to values less than ~100ms.
86
+ // See https://github.com/livestorejs/livestore/issues/916
87
+ React.useEffect(() => storeRegistry.retain(options), [storeRegistry, options])
88
+
89
+ return withReactApi(store)
17
90
  }
18
91
 
19
- export const useStore = (options?: { store?: Store }): { store: Store & ReactApi } => {
20
- if (options?.store !== undefined) {
21
- return { store: withReactApi(options.store) }
22
- }
92
+ /**
93
+ * React-specific methods added to the Store when used via React hooks.
94
+ *
95
+ * These methods are attached by `withReactApi()` and `useStore()`, allowing you
96
+ * to call `store.useQuery()` and `store.useClientDocument()` directly on the
97
+ * Store instance.
98
+ */
99
+ export type ReactApi = {
100
+ /** Hook version of query subscription—re-renders component when query result changes */
101
+ useQuery: typeof useQuery
102
+ /** Hook for reading and writing client-document tables with React state semantics */
103
+ useClientDocument: typeof useClientDocument
104
+ /** Hook for subscribing to sync status changes */
105
+ useSyncStatus: () => SyncStatus
106
+ }
23
107
 
24
- // biome-ignore lint/correctness/useHookAtTopLevel: store is stable
25
- const storeContext = React.useContext(LiveStoreContext)
108
+ /**
109
+ * Augments a Store instance with React-specific methods (`useQuery`, `useClientDocument`).
110
+ *
111
+ * This is called automatically by `useStore()`. You typically don't need to call it
112
+ * directly unless you're building custom integrations.
113
+ *
114
+ * @internal
115
+ */
116
+ export const withReactApi = <TSchema extends LiveStoreSchema, TContext = {}>(
117
+ store: Store<TSchema, TContext>,
118
+ ): Store<TSchema, TContext> & ReactApi => {
119
+ // @ts-expect-error TODO properly implement this
120
+ store.useQuery = (queryable) => useQuery(queryable, { store })
26
121
 
27
- if (storeContext === undefined) {
28
- throw new Error(`useStore can only be used inside StoreContext.Provider`)
29
- }
122
+ // @ts-expect-error TODO properly implement this
123
+ store.useClientDocument = (table, idOrOptions, options) => useClientDocument(table, idOrOptions, options, { store })
30
124
 
31
- if (storeContext.stage !== 'running') {
32
- throw new Error(`useStore can only be used after the store is running`)
33
- }
125
+ // @ts-expect-error TODO properly implement this
126
+ store.useSyncStatus = () => useSyncStatus({ store })
34
127
 
35
- return { store: withReactApi(storeContext.store) }
128
+ return store as Store<TSchema, TContext> & ReactApi
36
129
  }