@livestore/react 0.4.0-dev.21 → 0.4.0-dev.23

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 (125) 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 +8 -280
  8. package/dist/__tests__/fixture.d.ts.map +1 -1
  9. package/dist/__tests__/fixture.js +9 -84
  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 +9 -7
  14. package/dist/experimental/components/LiveList.js.map +1 -1
  15. package/dist/experimental/mod.d.ts +0 -1
  16. package/dist/experimental/mod.d.ts.map +1 -1
  17. package/dist/experimental/mod.js +0 -1
  18. package/dist/experimental/mod.js.map +1 -1
  19. package/dist/mod.d.ts +8 -5
  20. package/dist/mod.d.ts.map +1 -1
  21. package/dist/mod.js +6 -4
  22. package/dist/mod.js.map +1 -1
  23. package/dist/useClientDocument.d.ts +1 -26
  24. package/dist/useClientDocument.d.ts.map +1 -1
  25. package/dist/useClientDocument.js +3 -17
  26. package/dist/useClientDocument.js.map +1 -1
  27. package/dist/useClientDocument.test.js +12 -4
  28. package/dist/useClientDocument.test.js.map +1 -1
  29. package/dist/useQuery.d.ts +4 -5
  30. package/dist/useQuery.d.ts.map +1 -1
  31. package/dist/useQuery.js +12 -85
  32. package/dist/useQuery.js.map +1 -1
  33. package/dist/useQuery.test.js +7 -8
  34. package/dist/useQuery.test.js.map +1 -1
  35. package/dist/useRcResource.d.ts.map +1 -1
  36. package/dist/useRcResource.js +9 -5
  37. package/dist/useRcResource.js.map +1 -1
  38. package/dist/useRcResource.test.js +1 -1
  39. package/dist/useRcResource.test.js.map +1 -1
  40. package/dist/useStore.d.ts +61 -46
  41. package/dist/useStore.d.ts.map +1 -1
  42. package/dist/useStore.js +75 -60
  43. package/dist/useStore.js.map +1 -1
  44. package/dist/useStore.test.d.ts.map +1 -0
  45. package/dist/{experimental/multi-store/useStore.test.js → useStore.test.js} +70 -27
  46. package/dist/useStore.test.js.map +1 -0
  47. package/dist/useSyncStatus.d.ts +22 -0
  48. package/dist/useSyncStatus.d.ts.map +1 -0
  49. package/dist/useSyncStatus.js +28 -0
  50. package/dist/useSyncStatus.js.map +1 -0
  51. package/package.json +69 -26
  52. package/src/StoreRegistryContext.tsx +70 -0
  53. package/src/__snapshots__/useClientDocument.test.tsx.snap +112 -78
  54. package/src/__tests__/fixture.tsx +23 -118
  55. package/src/experimental/components/LiveList.tsx +22 -9
  56. package/src/experimental/mod.ts +0 -1
  57. package/src/mod.ts +8 -12
  58. package/src/useClientDocument.test.tsx +16 -6
  59. package/src/useClientDocument.ts +7 -61
  60. package/src/useQuery.test.tsx +8 -8
  61. package/src/useQuery.ts +30 -119
  62. package/src/useRcResource.test.tsx +1 -1
  63. package/src/useRcResource.ts +10 -5
  64. package/src/{experimental/multi-store/useStore.test.tsx → useStore.test.tsx} +117 -39
  65. package/src/useStore.ts +106 -65
  66. package/src/useSyncStatus.ts +34 -0
  67. package/dist/LiveStoreContext.d.ts +0 -40
  68. package/dist/LiveStoreContext.d.ts.map +0 -1
  69. package/dist/LiveStoreContext.js +0 -21
  70. package/dist/LiveStoreContext.js.map +0 -1
  71. package/dist/LiveStoreProvider.d.ts +0 -73
  72. package/dist/LiveStoreProvider.d.ts.map +0 -1
  73. package/dist/LiveStoreProvider.js +0 -233
  74. package/dist/LiveStoreProvider.js.map +0 -1
  75. package/dist/LiveStoreProvider.test.d.ts +0 -2
  76. package/dist/LiveStoreProvider.test.d.ts.map +0 -1
  77. package/dist/LiveStoreProvider.test.js +0 -117
  78. package/dist/LiveStoreProvider.test.js.map +0 -1
  79. package/dist/experimental/multi-store/StoreRegistry.d.ts +0 -105
  80. package/dist/experimental/multi-store/StoreRegistry.d.ts.map +0 -1
  81. package/dist/experimental/multi-store/StoreRegistry.js +0 -184
  82. package/dist/experimental/multi-store/StoreRegistry.js.map +0 -1
  83. package/dist/experimental/multi-store/StoreRegistry.test.d.ts +0 -2
  84. package/dist/experimental/multi-store/StoreRegistry.test.d.ts.map +0 -1
  85. package/dist/experimental/multi-store/StoreRegistry.test.js +0 -381
  86. package/dist/experimental/multi-store/StoreRegistry.test.js.map +0 -1
  87. package/dist/experimental/multi-store/StoreRegistryContext.d.ts +0 -10
  88. package/dist/experimental/multi-store/StoreRegistryContext.d.ts.map +0 -1
  89. package/dist/experimental/multi-store/StoreRegistryContext.js +0 -15
  90. package/dist/experimental/multi-store/StoreRegistryContext.js.map +0 -1
  91. package/dist/experimental/multi-store/mod.d.ts +0 -6
  92. package/dist/experimental/multi-store/mod.d.ts.map +0 -1
  93. package/dist/experimental/multi-store/mod.js +0 -6
  94. package/dist/experimental/multi-store/mod.js.map +0 -1
  95. package/dist/experimental/multi-store/storeOptions.d.ts +0 -4
  96. package/dist/experimental/multi-store/storeOptions.d.ts.map +0 -1
  97. package/dist/experimental/multi-store/storeOptions.js +0 -4
  98. package/dist/experimental/multi-store/storeOptions.js.map +0 -1
  99. package/dist/experimental/multi-store/types.d.ts +0 -25
  100. package/dist/experimental/multi-store/types.d.ts.map +0 -1
  101. package/dist/experimental/multi-store/types.js +0 -2
  102. package/dist/experimental/multi-store/types.js.map +0 -1
  103. package/dist/experimental/multi-store/useStore.d.ts +0 -11
  104. package/dist/experimental/multi-store/useStore.d.ts.map +0 -1
  105. package/dist/experimental/multi-store/useStore.js +0 -16
  106. package/dist/experimental/multi-store/useStore.js.map +0 -1
  107. package/dist/experimental/multi-store/useStore.test.d.ts.map +0 -1
  108. package/dist/experimental/multi-store/useStore.test.js.map +0 -1
  109. package/dist/utils/stack-info.d.ts +0 -4
  110. package/dist/utils/stack-info.d.ts.map +0 -1
  111. package/dist/utils/stack-info.js +0 -10
  112. package/dist/utils/stack-info.js.map +0 -1
  113. package/src/LiveStoreContext.ts +0 -41
  114. package/src/LiveStoreProvider.test.tsx +0 -248
  115. package/src/LiveStoreProvider.tsx +0 -430
  116. package/src/ambient.d.ts +0 -1
  117. package/src/experimental/multi-store/StoreRegistry.test.ts +0 -518
  118. package/src/experimental/multi-store/StoreRegistry.ts +0 -253
  119. package/src/experimental/multi-store/StoreRegistryContext.tsx +0 -23
  120. package/src/experimental/multi-store/mod.ts +0 -5
  121. package/src/experimental/multi-store/storeOptions.ts +0 -8
  122. package/src/experimental/multi-store/types.ts +0 -37
  123. package/src/experimental/multi-store/useStore.ts +0 -26
  124. package/src/utils/stack-info.ts +0 -13
  125. /package/dist/{experimental/multi-store/useStore.test.d.ts → useStore.test.d.ts} +0 -0
@@ -1,25 +1,28 @@
1
1
  import { makeInMemoryAdapter } from '@livestore/adapter-web'
2
- import type { Store } from '@livestore/livestore'
3
- import { StoreInternalsSymbol } from '@livestore/livestore'
2
+ import {
3
+ type RegistryStoreOptions,
4
+ type Store,
5
+ StoreInternalsSymbol,
6
+ StoreRegistry,
7
+ storeOptions,
8
+ } from '@livestore/livestore'
4
9
  import { shouldNeverHappen } from '@livestore/utils'
5
- import { act, type RenderHookResult, type RenderResult, render, renderHook, waitFor } from '@testing-library/react'
10
+ import { act, type RenderHookResult, type RenderResult, fireEvent, render, renderHook, waitFor } from '@testing-library/react'
6
11
  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'
12
+ import { describe, expect, it, vi } from 'vitest'
13
+
14
+ import { schema } from './__tests__/fixture.tsx'
10
15
  import { StoreRegistryProvider } from './StoreRegistryContext.tsx'
11
- import { storeOptions } from './storeOptions.ts'
12
- import type { CachedStoreOptions } from './types.ts'
13
16
  import { useStore } from './useStore.ts'
14
17
 
15
18
  describe('experimental useStore', () => {
16
19
  it('should return the same promise instance for concurrent getOrLoadStore calls', async () => {
17
- const registry = new StoreRegistry()
20
+ const storeRegistry = new StoreRegistry()
18
21
  const options = testStoreOptions()
19
22
 
20
23
  // Make two concurrent calls during loading
21
- const firstStore = registry.getOrLoadPromise(options)
22
- const secondStore = registry.getOrLoadPromise(options)
24
+ const firstStore = storeRegistry.getOrLoadPromise(options)
25
+ const secondStore = storeRegistry.getOrLoadPromise(options)
23
26
 
24
27
  // Both should be promises (store is loading)
25
28
  expect(firstStore).toBeInstanceOf(Promise)
@@ -35,14 +38,14 @@ describe('experimental useStore', () => {
35
38
  })
36
39
 
37
40
  it('works with Suspense boundary', async () => {
38
- const registry = new StoreRegistry()
41
+ const storeRegistry = new StoreRegistry()
39
42
  const options = testStoreOptions()
40
43
 
41
44
  let view: RenderResult | undefined
42
45
  await act(async () => {
43
46
  view = render(
44
- <StoreRegistryProvider storeRegistry={registry}>
45
- <React.Suspense fallback={<div data-testid="fallback" />}>
47
+ <StoreRegistryProvider storeRegistry={storeRegistry}>
48
+ <React.Suspense fallback={makeSuspenseFallback()}>
46
49
  <StoreConsumer options={options} />
47
50
  </React.Suspense>
48
51
  </StoreRegistryProvider>,
@@ -58,12 +61,11 @@ describe('experimental useStore', () => {
58
61
  })
59
62
 
60
63
  it('does not re-suspend on subsequent renders when store is already loaded', async () => {
61
- const registry = new StoreRegistry()
64
+ const storeRegistry = new StoreRegistry()
62
65
  const options = testStoreOptions()
63
-
64
- const Wrapper = ({ opts }: { opts: CachedStoreOptions<typeof schema> }) => (
65
- <StoreRegistryProvider storeRegistry={registry}>
66
- <React.Suspense fallback={<div data-testid="fallback" />}>
66
+ const Wrapper = ({ opts }: { opts: RegistryStoreOptions<typeof schema> }) => (
67
+ <StoreRegistryProvider storeRegistry={storeRegistry}>
68
+ <React.Suspense fallback={makeSuspenseFallback()}>
67
69
  <StoreConsumer options={opts} />
68
70
  </React.Suspense>
69
71
  </StoreRegistryProvider>
@@ -80,8 +82,9 @@ describe('experimental useStore', () => {
80
82
  expect(renderedView.getByTestId('ready')).toBeDefined()
81
83
 
82
84
  // Rerender with new options object (but same storeId)
85
+ const rerenderOptions = cloneStoreOptions(options)
83
86
  await act(async () => {
84
- renderedView.rerender(<Wrapper opts={{ ...options }} />)
87
+ renderedView.rerender(<Wrapper opts={rerenderOptions} />)
85
88
  })
86
89
 
87
90
  // Should not show fallback
@@ -92,19 +95,19 @@ describe('experimental useStore', () => {
92
95
  })
93
96
 
94
97
  it('throws when store loading fails', async () => {
95
- const registry = new StoreRegistry()
98
+ const storeRegistry = new StoreRegistry()
96
99
  const badOptions = testStoreOptions({
97
100
  // @ts-expect-error - intentionally passing invalid adapter to trigger error
98
101
  adapter: null,
99
102
  })
100
103
 
101
104
  // Pre-load the store to cache the error (error happens synchronously)
102
- expect(() => registry.getOrLoadPromise(badOptions)).toThrow()
105
+ expect(() => storeRegistry.getOrLoadPromise(badOptions)).toThrow()
103
106
 
104
107
  // Now when useStore tries to get it, it should throw synchronously
105
108
  expect(() =>
106
109
  renderHook(() => useStore(badOptions), {
107
- wrapper: makeProvider(registry),
110
+ wrapper: makeProvider(storeRegistry),
108
111
  }),
109
112
  ).toThrow()
110
113
  })
@@ -113,13 +116,13 @@ describe('experimental useStore', () => {
113
116
  { label: 'non-strict mode', strictMode: false },
114
117
  { label: 'strict mode', strictMode: true },
115
118
  ])('works in $label', async ({ strictMode }) => {
116
- const registry = new StoreRegistry()
119
+ const storeRegistry = new StoreRegistry()
117
120
  const options = testStoreOptions()
118
121
 
119
- let hook: RenderHookResult<Store<typeof schema>, CachedStoreOptions<typeof schema>> | undefined
122
+ let hook: RenderHookResult<Store<typeof schema>, RegistryStoreOptions<typeof schema>> | undefined
120
123
  await act(async () => {
121
124
  hook = renderHook(() => useStore(options), {
122
- wrapper: makeProvider(registry, { suspense: true }),
125
+ wrapper: makeProvider(storeRegistry, { suspense: true }),
123
126
  reactStrictMode: strictMode,
124
127
  })
125
128
  })
@@ -133,16 +136,16 @@ describe('experimental useStore', () => {
133
136
  })
134
137
 
135
138
  it('handles switching between different storeId values', async () => {
136
- const registry = new StoreRegistry()
139
+ const storeRegistry = new StoreRegistry()
137
140
 
138
141
  const optionsA = testStoreOptions({ storeId: 'store-a' })
139
142
  const optionsB = testStoreOptions({ storeId: 'store-b' })
140
143
 
141
- let hook: RenderHookResult<Store<typeof schema>, CachedStoreOptions<typeof schema>> | undefined
144
+ let hook: RenderHookResult<Store<typeof schema>, RegistryStoreOptions<typeof schema>> | undefined
142
145
  await act(async () => {
143
146
  hook = renderHook((opts) => useStore(opts), {
144
147
  initialProps: optionsA,
145
- wrapper: makeProvider(registry, { suspense: true }),
148
+ wrapper: makeProvider(storeRegistry, { suspense: true }),
146
149
  })
147
150
  })
148
151
  const { result, rerender, unmount } = hook ?? shouldNeverHappen('renderHook failed')
@@ -170,13 +173,58 @@ describe('experimental useStore', () => {
170
173
  await cleanupAfterUnmount(unmount)
171
174
  })
172
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
+
173
222
  // useStore doesn't handle unusedCacheTime=0 correctly because retain is called in useEffect (after render)
174
223
  // See https://github.com/livestorejs/livestore/issues/916
175
224
  it.skip('should load store with unusedCacheTime set to 0', async () => {
176
- const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime: 0 } })
225
+ const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime: 0 } })
177
226
  const options = testStoreOptions({ unusedCacheTime: 0 })
178
-
179
- const StoreConsumerWithVerification = ({ opts }: { opts: CachedStoreOptions<typeof schema> }) => {
227
+ const StoreConsumerWithVerification = ({ opts }: { opts: RegistryStoreOptions<typeof schema> }) => {
180
228
  const store = useStore(opts)
181
229
  // Verify store is usable - access internals to confirm it's not disposed
182
230
  const clientSession = store[StoreInternalsSymbol].clientSession
@@ -186,8 +234,8 @@ describe('experimental useStore', () => {
186
234
  let view: RenderResult | undefined
187
235
  await act(async () => {
188
236
  view = render(
189
- <StoreRegistryProvider storeRegistry={registry}>
190
- <React.Suspense fallback={<div data-testid="fallback" />}>
237
+ <StoreRegistryProvider storeRegistry={storeRegistry}>
238
+ <React.Suspense fallback={makeSuspenseFallback()}>
191
239
  <StoreConsumerWithVerification opts={options} />
192
240
  </React.Suspense>
193
241
  </StoreRegistryProvider>,
@@ -211,17 +259,47 @@ describe('experimental useStore', () => {
211
259
  })
212
260
  })
213
261
 
214
- const StoreConsumer = ({ options }: { options: CachedStoreOptions<any> }) => {
262
+ const StoreConsumer = ({ options }: { options: RegistryStoreOptions<any> }) => {
215
263
  useStore(options)
216
264
  return <div data-testid="ready" />
217
265
  }
218
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
+
219
297
  const makeProvider =
220
- (registry: StoreRegistry, { suspense = false }: { suspense?: boolean } = {}) =>
298
+ (storeRegistry: StoreRegistry, { suspense = false }: { suspense?: boolean } = {}) =>
221
299
  ({ children }: { children: React.ReactNode }) => {
222
- let content = <StoreRegistryProvider storeRegistry={registry}>{children}</StoreRegistryProvider>
300
+ let content = <StoreRegistryProvider storeRegistry={storeRegistry}>{children}</StoreRegistryProvider>
223
301
 
224
- if (suspense) {
302
+ if (suspense !== undefined) {
225
303
  content = <React.Suspense fallback={null}>{content}</React.Suspense>
226
304
  }
227
305
 
@@ -230,7 +308,7 @@ const makeProvider =
230
308
 
231
309
  let testStoreCounter = 0
232
310
 
233
- const testStoreOptions = (overrides: Partial<CachedStoreOptions<typeof schema>> = {}) =>
311
+ const testStoreOptions = (overrides: Partial<RegistryStoreOptions<typeof schema>> = {}) =>
234
312
  storeOptions({
235
313
  storeId: overrides.storeId ?? `test-store-${testStoreCounter++}`,
236
314
  schema,
package/src/useStore.ts CHANGED
@@ -1,88 +1,129 @@
1
- import type { LiveStoreSchema } from '@livestore/common/schema'
2
- import type { Store } from '@livestore/livestore'
3
1
  import React from 'react'
4
2
 
5
- import type { ReactApi } from './LiveStoreContext.ts'
6
- 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'
7
8
  import { useClientDocument } from './useClientDocument.ts'
8
9
  import { useQuery } from './useQuery.ts'
10
+ import { useSyncStatus } from './useSyncStatus.ts'
9
11
 
10
12
  /**
11
- * Augments a Store instance with React-specific methods (`useQuery`, `useClientDocument`).
12
- *
13
- * This is called automatically by `useStore()` and `LiveStoreProvider`. You typically
14
- * don't need to call it directly unless you're building custom integrations.
13
+ * Returns a store instance augmented with hooks (`store.useQuery()` and `store.useClientDocument()`) for reactive queries.
15
14
  *
16
15
  * @example
17
- * ```ts
18
- * // Usually not needed—useStore() does this automatically
19
- * const store = withReactApi(myStore)
20
- * const todos = store.useQuery(tables.todos.all())
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
+ * }
21
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>`
22
56
  */
23
- export const withReactApi = <TSchema extends LiveStoreSchema>(store: Store<TSchema>): Store<TSchema> & ReactApi => {
24
- // @ts-expect-error TODO properly implement this
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()
25
65
 
26
- store.useQuery = (queryable) => useQuery(queryable, { store })
27
- // @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)
28
71
 
29
- store.useClientDocument = (table, idOrOptions, options) => useClientDocument(table, idOrOptions, options, { store })
30
- return store as Store<TSchema> & 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)
31
90
  }
32
91
 
33
92
  /**
34
- * Returns the current Store instance from React context, augmented with React-specific methods.
35
- *
36
- * Use this hook when you need direct access to the Store for operations like
37
- * `store.commit()`, `store.subscribe()`, or accessing `store.sessionId`.
38
- *
39
- * For reactive queries, prefer `useQuery()` or `useClientDocument()` which handle
40
- * subscriptions and re-renders automatically.
41
- *
42
- * @example
43
- * ```ts
44
- * const MyComponent = () => {
45
- * const { store } = useStore()
46
- *
47
- * const handleClick = () => {
48
- * store.commit(events.todoCreated({ id: nanoid(), text: 'New todo' }))
49
- * }
50
- *
51
- * return <button onClick={handleClick}>Add Todo</button>
52
- * }
53
- * ```
93
+ * React-specific methods added to the Store when used via React hooks.
54
94
  *
55
- * @example
56
- * ```ts
57
- * // Access store metadata
58
- * const { store } = useStore()
59
- * console.log('Session ID:', store.sessionId)
60
- * console.log('Client ID:', store.clientId)
61
- * ```
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
+ }
107
+
108
+ /**
109
+ * Augments a Store instance with React-specific methods (`useQuery`, `useClientDocument`).
62
110
  *
63
- * @example
64
- * ```ts
65
- * // Use with an explicit store instance (bypasses context)
66
- * const { store } = useStore({ store: myExternalStore })
67
- * ```
111
+ * This is called automatically by `useStore()`. You typically don't need to call it
112
+ * directly unless you're building custom integrations.
68
113
  *
69
- * @throws Error if called outside of `<LiveStoreProvider>` or before the store is running
114
+ * @internal
70
115
  */
71
- export const useStore = (options?: { store?: Store }): { store: Store & ReactApi } => {
72
- if (options?.store !== undefined) {
73
- return { store: withReactApi(options.store) }
74
- }
75
-
76
- // biome-ignore lint/correctness/useHookAtTopLevel: store is stable
77
- const storeContext = React.useContext(LiveStoreContext)
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 })
78
121
 
79
- if (storeContext === undefined) {
80
- throw new Error(`useStore can only be used inside StoreContext.Provider`)
81
- }
122
+ // @ts-expect-error TODO properly implement this
123
+ store.useClientDocument = (table, idOrOptions, options) => useClientDocument(table, idOrOptions, options, { store })
82
124
 
83
- if (storeContext.stage !== 'running') {
84
- throw new Error(`useStore can only be used after the store is running`)
85
- }
125
+ // @ts-expect-error TODO properly implement this
126
+ store.useSyncStatus = () => useSyncStatus({ store })
86
127
 
87
- return { store: withReactApi(storeContext.store) }
128
+ return store as Store<TSchema, TContext> & ReactApi
88
129
  }
@@ -0,0 +1,34 @@
1
+ import React from 'react'
2
+
3
+ import type { Store, SyncStatus } from '@livestore/livestore'
4
+
5
+ /**
6
+ * React hook that subscribes to sync status changes.
7
+ *
8
+ * Returns the current synchronization status between the client session and
9
+ * the leader thread. The component re-renders whenever the sync status changes.
10
+ *
11
+ * @example
12
+ * ```tsx
13
+ * function SyncIndicator() {
14
+ * const status = store.useSyncStatus()
15
+ * return <span>{status.isSynced ? '✓ Synced' : `Syncing (${status.pendingCount} pending)...`}</span>
16
+ * }
17
+ * ```
18
+ *
19
+ * @param options - Options containing the store instance
20
+ * @returns The current sync status
21
+ */
22
+ export const useSyncStatus = (options: { store: Store<any> }): SyncStatus => {
23
+ const { store } = options
24
+
25
+ const [status, setStatus] = React.useState<SyncStatus>(() => store.syncStatus())
26
+
27
+ React.useEffect(() => {
28
+ return store.subscribeSyncStatus(setStatus)
29
+ }, [store])
30
+
31
+ React.useDebugValue(`LiveStore:useSyncStatus:${status.isSynced === true ? 'synced' : 'pending'}`)
32
+
33
+ return status
34
+ }
@@ -1,40 +0,0 @@
1
- import type { LiveStoreContextRunning } from '@livestore/livestore';
2
- import React from 'react';
3
- import type { useClientDocument } from './useClientDocument.ts';
4
- import type { useQuery } from './useQuery.ts';
5
- /**
6
- * React-specific methods added to the Store when used via React hooks.
7
- *
8
- * These methods are attached by `withReactApi()` and `useStore()`, allowing you
9
- * to call `store.useQuery()` and `store.useClientDocument()` directly on the
10
- * Store instance.
11
- */
12
- export type ReactApi = {
13
- /** Hook version of query subscription—re-renders component when query result changes */
14
- useQuery: typeof useQuery;
15
- /** Hook for reading and writing client-document tables with React state semantics */
16
- useClientDocument: typeof useClientDocument;
17
- };
18
- /**
19
- * React context for accessing the LiveStore instance.
20
- *
21
- * This context is provided by `<LiveStoreProvider>` and consumed by hooks like
22
- * `useStore()`, `useQuery()`, and `useClientDocument()`.
23
- *
24
- * The context value is `undefined` until the Store has finished booting,
25
- * then transitions to `{ stage: 'running', store: ... }`.
26
- *
27
- * @example
28
- * ```tsx
29
- * // Typically you don't use this directly—use useStore() instead
30
- * const context = React.useContext(LiveStoreContext)
31
- * if (context?.stage === 'running') {
32
- * console.log('Store ready:', context.store.storeId)
33
- * }
34
- * ```
35
- */
36
- export declare const LiveStoreContext: React.Context<{
37
- stage: "running";
38
- store: LiveStoreContextRunning["store"] & ReactApi;
39
- } | undefined>;
40
- //# sourceMappingURL=LiveStoreContext.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"LiveStoreContext.d.ts","sourceRoot":"","sources":["../src/LiveStoreContext.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAA;AACnE,OAAO,KAAK,MAAM,OAAO,CAAA;AAEzB,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAC/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAE7C;;;;;;GAMG;AACH,MAAM,MAAM,QAAQ,GAAG;IACrB,wFAAwF;IACxF,QAAQ,EAAE,OAAO,QAAQ,CAAA;IACzB,qFAAqF;IACrF,iBAAiB,EAAE,OAAO,iBAAiB,CAAA;CAC5C,CAAA;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,gBAAgB;WAClB,SAAS;WAAS,uBAAuB,CAAC,OAAO,CAAC,GAAG,QAAQ;cAC5D,CAAA"}
@@ -1,21 +0,0 @@
1
- import React from 'react';
2
- /**
3
- * React context for accessing the LiveStore instance.
4
- *
5
- * This context is provided by `<LiveStoreProvider>` and consumed by hooks like
6
- * `useStore()`, `useQuery()`, and `useClientDocument()`.
7
- *
8
- * The context value is `undefined` until the Store has finished booting,
9
- * then transitions to `{ stage: 'running', store: ... }`.
10
- *
11
- * @example
12
- * ```tsx
13
- * // Typically you don't use this directly—use useStore() instead
14
- * const context = React.useContext(LiveStoreContext)
15
- * if (context?.stage === 'running') {
16
- * console.log('Store ready:', context.store.storeId)
17
- * }
18
- * ```
19
- */
20
- export const LiveStoreContext = React.createContext(undefined);
21
- //# sourceMappingURL=LiveStoreContext.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"LiveStoreContext.js","sourceRoot":"","sources":["../src/LiveStoreContext.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,MAAM,OAAO,CAAA;AAmBzB;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,KAAK,CAAC,aAAa,CAEjD,SAAS,CAAC,CAAA"}
@@ -1,73 +0,0 @@
1
- import type { Adapter, BootStatus, IntentionalShutdownCause, MigrationsReport, SyncError } from '@livestore/common';
2
- import { LogConfig, UnknownError } from '@livestore/common';
3
- import type { LiveStoreSchema } from '@livestore/common/schema';
4
- import type { CreateStoreOptions, OtelOptions, Store } from '@livestore/livestore';
5
- import { StoreInterrupted } from '@livestore/livestore';
6
- import type { OtelTracer } from '@livestore/utils/effect';
7
- import { Effect, Schema } from '@livestore/utils/effect';
8
- import type * as otel from '@opentelemetry/api';
9
- import React from 'react';
10
- export interface LiveStoreProviderProps<TSyncPayloadSchema extends Schema.Schema<any> = typeof Schema.JsonValue> extends LogConfig.WithLoggerOptions {
11
- schema: LiveStoreSchema;
12
- /**
13
- * The `storeId` can be used to isolate multiple stores from each other.
14
- * So it can be useful for multi-tenancy scenarios.
15
- *
16
- * The `storeId` is also used for persistence.
17
- *
18
- * Make sure to also configure `storeId` in LiveStore Devtools (e.g. in Vite plugin).
19
- *
20
- * @default 'default'
21
- */
22
- storeId?: string;
23
- boot?: (store: Store<LiveStoreSchema>, ctx: {
24
- migrationsReport: MigrationsReport;
25
- parentSpan: otel.Span;
26
- }) => void | Promise<void> | Effect.Effect<void, unknown, OtelTracer.OtelTracer>;
27
- otelOptions?: Partial<OtelOptions>;
28
- renderLoading?: (status: BootStatus) => React.ReactNode;
29
- renderError?: (error: UnknownError | unknown) => React.ReactNode;
30
- renderShutdown?: (cause: IntentionalShutdownCause | StoreInterrupted | SyncError) => React.ReactNode;
31
- adapter: Adapter;
32
- /**
33
- * In order for LiveStore to apply multiple events in a single render,
34
- * you need to pass the `batchUpdates` function from either `react-dom` or `react-native`.
35
- *
36
- * ```ts
37
- * // With React DOM
38
- * import { unstable_batchedUpdates as batchUpdates } from 'react-dom'
39
- *
40
- * // With React Native
41
- * import { unstable_batchedUpdates as batchUpdates } from 'react-native'
42
- * ```
43
- */
44
- batchUpdates: (run: () => void) => void;
45
- disableDevtools?: boolean;
46
- signal?: AbortSignal;
47
- /**
48
- * Currently only used in the web adapter:
49
- * If true, registers a beforeunload event listener to confirm unsaved changes.
50
- *
51
- * @default true
52
- */
53
- confirmUnsavedChanges?: boolean;
54
- /**
55
- * Advanced store parameters forwarded to `createStore`.
56
- * Currently supports:
57
- * - `leaderPushBatchSize`: max events pushed to the leader per write batch.
58
- * - `eventQueryBatchSize`: chunk size used when the stream replays confirmed events.
59
- */
60
- params?: CreateStoreOptions<LiveStoreSchema>['params'];
61
- /**
62
- * Payload that will be passed to the sync backend when connecting
63
- *
64
- * @default undefined
65
- */
66
- syncPayloadSchema?: TSyncPayloadSchema;
67
- syncPayload?: Schema.Schema.Type<TSyncPayloadSchema>;
68
- debug?: {
69
- instanceId?: string;
70
- };
71
- }
72
- export declare const LiveStoreProvider: <TSyncPayloadSchema extends Schema.Schema<any> = typeof Schema.JsonValue>({ renderLoading, renderError, renderShutdown, otelOptions, children, schema, storeId, boot, adapter, batchUpdates, disableDevtools, signal, confirmUnsavedChanges, params, syncPayload, syncPayloadSchema, debug, logger, logLevel, }: LiveStoreProviderProps<TSyncPayloadSchema> & React.PropsWithChildren) => React.ReactNode;
73
- //# sourceMappingURL=LiveStoreProvider.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"LiveStoreProvider.d.ts","sourceRoot":"","sources":["../src/LiveStoreProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,wBAAwB,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AACnH,OAAO,EAAE,SAAS,EAAe,YAAY,EAAE,MAAM,mBAAmB,CAAA;AACxE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAA;AAC/D,OAAO,KAAK,EACV,kBAAkB,EAClB,WAAW,EAEX,KAAK,EAEN,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAqC,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AAE1F,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAA;AACzD,OAAO,EAAmB,MAAM,EAAkB,MAAM,EAAsB,MAAM,yBAAyB,CAAA;AAC7G,OAAO,KAAK,KAAK,IAAI,MAAM,oBAAoB,CAAA;AAC/C,OAAO,KAAK,MAAM,OAAO,CAAA;AAIzB,MAAM,WAAW,sBAAsB,CAAC,kBAAkB,SAAS,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,OAAO,MAAM,CAAC,SAAS,CAC7G,SAAQ,SAAS,CAAC,iBAAiB;IACnC,MAAM,EAAE,eAAe,CAAA;IACvB;;;;;;;;;OASG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,CACL,KAAK,EAAE,KAAK,CAAC,eAAe,CAAC,EAC7B,GAAG,EAAE;QAAE,gBAAgB,EAAE,gBAAgB,CAAC;QAAC,UAAU,EAAE,IAAI,CAAC,IAAI,CAAA;KAAE,KAC/D,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,OAAO,EAAE,UAAU,CAAC,UAAU,CAAC,CAAA;IAC/E,WAAW,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,CAAA;IAClC,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,KAAK,CAAC,SAAS,CAAA;IACvD,WAAW,CAAC,EAAE,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,KAAK,KAAK,CAAC,SAAS,CAAA;IAChE,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,wBAAwB,GAAG,gBAAgB,GAAG,SAAS,KAAK,KAAK,CAAC,SAAS,CAAA;IACpG,OAAO,EAAE,OAAO,CAAA;IAChB;;;;;;;;;;;OAWG;IACH,YAAY,EAAE,CAAC,GAAG,EAAE,MAAM,IAAI,KAAK,IAAI,CAAA;IACvC,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB;;;;;OAKG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAA;IAC/B;;;;;OAKG;IACH,MAAM,CAAC,EAAE,kBAAkB,CAAC,eAAe,CAAC,CAAC,QAAQ,CAAC,CAAA;IACtD;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,kBAAkB,CAAA;IACtC,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,kBAAkB,CAAC,CAAA;IACpD,KAAK,CAAC,EAAE;QACN,UAAU,CAAC,EAAE,MAAM,CAAA;KACpB,CAAA;CACF;AA2BD,eAAO,MAAM,iBAAiB,GAAI,kBAAkB,SAAS,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,OAAO,MAAM,CAAC,SAAS,EAAE,uOAoBxG,sBAAsB,CAAC,kBAAkB,CAAC,GAAG,KAAK,CAAC,iBAAiB,KAAG,KAAK,CAAC,SAwC/E,CAAA"}