@livestore/react 0.4.0-dev.22 → 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 (65) hide show
  1. package/README.md +1 -1
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/StoreRegistryContext.d.ts +1 -1
  4. package/dist/StoreRegistryContext.d.ts.map +1 -1
  5. package/dist/StoreRegistryContext.js +2 -2
  6. package/dist/StoreRegistryContext.js.map +1 -1
  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 +8 -78
  10. package/dist/__tests__/fixture.js.map +1 -1
  11. package/dist/experimental/components/LiveList.d.ts.map +1 -1
  12. package/dist/experimental/components/LiveList.js +5 -4
  13. package/dist/experimental/components/LiveList.js.map +1 -1
  14. package/dist/mod.d.ts +4 -2
  15. package/dist/mod.d.ts.map +1 -1
  16. package/dist/mod.js +3 -2
  17. package/dist/mod.js.map +1 -1
  18. package/dist/useClientDocument.d.ts +1 -26
  19. package/dist/useClientDocument.d.ts.map +1 -1
  20. package/dist/useClientDocument.js +2 -13
  21. package/dist/useClientDocument.js.map +1 -1
  22. package/dist/useClientDocument.test.js +12 -4
  23. package/dist/useClientDocument.test.js.map +1 -1
  24. package/dist/useQuery.d.ts +3 -4
  25. package/dist/useQuery.d.ts.map +1 -1
  26. package/dist/useQuery.js +10 -80
  27. package/dist/useQuery.js.map +1 -1
  28. package/dist/useQuery.test.js +7 -8
  29. package/dist/useQuery.test.js.map +1 -1
  30. package/dist/useRcResource.d.ts.map +1 -1
  31. package/dist/useRcResource.js +9 -5
  32. package/dist/useRcResource.js.map +1 -1
  33. package/dist/useRcResource.test.js +1 -1
  34. package/dist/useRcResource.test.js.map +1 -1
  35. package/dist/useStore.d.ts +12 -1
  36. package/dist/useStore.d.ts.map +1 -1
  37. package/dist/useStore.js +21 -13
  38. package/dist/useStore.js.map +1 -1
  39. package/dist/useStore.test.js +53 -8
  40. package/dist/useStore.test.js.map +1 -1
  41. package/dist/useSyncStatus.d.ts +22 -0
  42. package/dist/useSyncStatus.d.ts.map +1 -0
  43. package/dist/useSyncStatus.js +28 -0
  44. package/dist/useSyncStatus.js.map +1 -0
  45. package/package.json +68 -25
  46. package/src/StoreRegistryContext.tsx +4 -3
  47. package/src/__snapshots__/useClientDocument.test.tsx.snap +112 -78
  48. package/src/__tests__/fixture.tsx +22 -105
  49. package/src/experimental/components/LiveList.tsx +9 -5
  50. package/src/mod.ts +4 -9
  51. package/src/useClientDocument.test.tsx +16 -6
  52. package/src/useClientDocument.ts +6 -56
  53. package/src/useQuery.test.tsx +8 -8
  54. package/src/useQuery.ts +28 -113
  55. package/src/useRcResource.test.tsx +1 -1
  56. package/src/useRcResource.ts +10 -5
  57. package/src/useStore.test.tsx +85 -9
  58. package/src/useStore.ts +30 -17
  59. package/src/useSyncStatus.ts +34 -0
  60. package/dist/utils/stack-info.d.ts +0 -4
  61. package/dist/utils/stack-info.d.ts.map +0 -1
  62. package/dist/utils/stack-info.js +0 -10
  63. package/dist/utils/stack-info.js.map +0 -1
  64. package/src/ambient.d.ts +0 -1
  65. package/src/utils/stack-info.ts +0 -13
@@ -7,9 +7,10 @@ import {
7
7
  storeOptions,
8
8
  } from '@livestore/livestore'
9
9
  import { shouldNeverHappen } from '@livestore/utils'
10
- 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'
11
11
  import * as React from 'react'
12
- import { describe, expect, it } from 'vitest'
12
+ import { describe, expect, it, vi } from 'vitest'
13
+
13
14
  import { schema } from './__tests__/fixture.tsx'
14
15
  import { StoreRegistryProvider } from './StoreRegistryContext.tsx'
15
16
  import { useStore } from './useStore.ts'
@@ -44,7 +45,7 @@ describe('experimental useStore', () => {
44
45
  await act(async () => {
45
46
  view = render(
46
47
  <StoreRegistryProvider storeRegistry={storeRegistry}>
47
- <React.Suspense fallback={<div data-testid="fallback" />}>
48
+ <React.Suspense fallback={makeSuspenseFallback()}>
48
49
  <StoreConsumer options={options} />
49
50
  </React.Suspense>
50
51
  </StoreRegistryProvider>,
@@ -62,10 +63,9 @@ describe('experimental useStore', () => {
62
63
  it('does not re-suspend on subsequent renders when store is already loaded', async () => {
63
64
  const storeRegistry = new StoreRegistry()
64
65
  const options = testStoreOptions()
65
-
66
66
  const Wrapper = ({ opts }: { opts: RegistryStoreOptions<typeof schema> }) => (
67
67
  <StoreRegistryProvider storeRegistry={storeRegistry}>
68
- <React.Suspense fallback={<div data-testid="fallback" />}>
68
+ <React.Suspense fallback={makeSuspenseFallback()}>
69
69
  <StoreConsumer options={opts} />
70
70
  </React.Suspense>
71
71
  </StoreRegistryProvider>
@@ -82,8 +82,9 @@ describe('experimental useStore', () => {
82
82
  expect(renderedView.getByTestId('ready')).toBeDefined()
83
83
 
84
84
  // Rerender with new options object (but same storeId)
85
+ const rerenderOptions = cloneStoreOptions(options)
85
86
  await act(async () => {
86
- renderedView.rerender(<Wrapper opts={{ ...options }} />)
87
+ renderedView.rerender(<Wrapper opts={rerenderOptions} />)
87
88
  })
88
89
 
89
90
  // Should not show fallback
@@ -172,12 +173,57 @@ describe('experimental useStore', () => {
172
173
  await cleanupAfterUnmount(unmount)
173
174
  })
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
+
175
222
  // useStore doesn't handle unusedCacheTime=0 correctly because retain is called in useEffect (after render)
176
223
  // See https://github.com/livestorejs/livestore/issues/916
177
224
  it.skip('should load store with unusedCacheTime set to 0', async () => {
178
225
  const storeRegistry = new StoreRegistry({ defaultOptions: { unusedCacheTime: 0 } })
179
226
  const options = testStoreOptions({ unusedCacheTime: 0 })
180
-
181
227
  const StoreConsumerWithVerification = ({ opts }: { opts: RegistryStoreOptions<typeof schema> }) => {
182
228
  const store = useStore(opts)
183
229
  // Verify store is usable - access internals to confirm it's not disposed
@@ -189,7 +235,7 @@ describe('experimental useStore', () => {
189
235
  await act(async () => {
190
236
  view = render(
191
237
  <StoreRegistryProvider storeRegistry={storeRegistry}>
192
- <React.Suspense fallback={<div data-testid="fallback" />}>
238
+ <React.Suspense fallback={makeSuspenseFallback()}>
193
239
  <StoreConsumerWithVerification opts={options} />
194
240
  </React.Suspense>
195
241
  </StoreRegistryProvider>,
@@ -218,12 +264,42 @@ const StoreConsumer = ({ options }: { options: RegistryStoreOptions<any> }) => {
218
264
  return <div data-testid="ready" />
219
265
  }
220
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
+
221
297
  const makeProvider =
222
298
  (storeRegistry: StoreRegistry, { suspense = false }: { suspense?: boolean } = {}) =>
223
299
  ({ children }: { children: React.ReactNode }) => {
224
300
  let content = <StoreRegistryProvider storeRegistry={storeRegistry}>{children}</StoreRegistryProvider>
225
301
 
226
- if (suspense) {
302
+ if (suspense !== undefined) {
227
303
  content = <React.Suspense fallback={null}>{content}</React.Suspense>
228
304
  }
229
305
 
package/src/useStore.ts CHANGED
@@ -1,10 +1,13 @@
1
+ import React from 'react'
2
+
1
3
  import type { LiveStoreSchema } from '@livestore/common/schema'
2
- import type { RegistryStoreOptions, Store } from '@livestore/livestore'
4
+ import type { RegistryStoreOptions, Store, SyncStatus } from '@livestore/livestore'
3
5
  import type { Schema } from '@livestore/utils/effect'
4
- import React from 'react'
6
+
5
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
12
  /**
10
13
  * Returns a store instance augmented with hooks (`store.useQuery()` and `store.useClientDocument()`) for reactive queries.
@@ -60,24 +63,28 @@ export const useStore = <
60
63
  ): Store<TSchema, TContext> & ReactApi => {
61
64
  const storeRegistry = useStoreRegistry()
62
65
 
63
- // NOTE: retain() is called in useEffect (after render), while getOrLoadPromise() is called
64
- // in useMemo (during render). This creates a timing gap where with very short unusedCacheTime
65
- // values (e.g., 0), the store could theoretically be disposed before the effect fires.
66
- // In practice, this is not an issue with the default 60s cache time, but it becomes an issue when
67
- // `unusedCacheTime` is configured to values less than ~100ms.
68
- // See https://github.com/livestorejs/livestore/issues/916
69
- React.useEffect(() => storeRegistry.retain(options), [storeRegistry, options])
70
-
71
- const storeOrPromise = React.useMemo(() => storeRegistry.getOrLoadPromise(options), [storeRegistry, options])
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)
72
71
 
73
72
  const store = storeOrPromise instanceof Promise ? React.use(storeOrPromise) : storeOrPromise
74
73
 
75
- // Expose store on the global object for browser console debugging.
76
- globalThis.__debugLiveStore ??= {}
77
- if (Object.keys(globalThis.__debugLiveStore).length === 0) {
78
- globalThis.__debugLiveStore._ = store
79
- }
80
- globalThis.__debugLiveStore[options.debug?.instanceId ?? options.storeId] = store
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])
81
88
 
82
89
  return withReactApi(store)
83
90
  }
@@ -94,6 +101,8 @@ export type ReactApi = {
94
101
  useQuery: typeof useQuery
95
102
  /** Hook for reading and writing client-document tables with React state semantics */
96
103
  useClientDocument: typeof useClientDocument
104
+ /** Hook for subscribing to sync status changes */
105
+ useSyncStatus: () => SyncStatus
97
106
  }
98
107
 
99
108
  /**
@@ -112,5 +121,9 @@ export const withReactApi = <TSchema extends LiveStoreSchema, TContext = {}>(
112
121
 
113
122
  // @ts-expect-error TODO properly implement this
114
123
  store.useClientDocument = (table, idOrOptions, options) => useClientDocument(table, idOrOptions, options, { store })
124
+
125
+ // @ts-expect-error TODO properly implement this
126
+ store.useSyncStatus = () => useSyncStatus({ store })
127
+
115
128
  return store as Store<TSchema, TContext> & ReactApi
116
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,4 +0,0 @@
1
- import { type StackInfo } from '@livestore/livestore';
2
- export declare const originalStackLimit: number;
3
- export declare const useStackInfo: () => StackInfo;
4
- //# sourceMappingURL=stack-info.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"stack-info.d.ts","sourceRoot":"","sources":["../../src/utils/stack-info.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkC,KAAK,SAAS,EAAE,MAAM,sBAAsB,CAAA;AAGrF,eAAO,MAAM,kBAAkB,QAAwB,CAAA;AAEvD,eAAO,MAAM,YAAY,QAAO,SAOxB,CAAA"}
@@ -1,10 +0,0 @@
1
- import { extractStackInfoFromStackTrace } from '@livestore/livestore';
2
- import React from 'react';
3
- export const originalStackLimit = Error.stackTraceLimit;
4
- export const useStackInfo = () => React.useMemo(() => {
5
- Error.stackTraceLimit = 10;
6
- const stack = new Error().stack;
7
- Error.stackTraceLimit = originalStackLimit;
8
- return extractStackInfoFromStackTrace(stack);
9
- }, []);
10
- //# sourceMappingURL=stack-info.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"stack-info.js","sourceRoot":"","sources":["../../src/utils/stack-info.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,8BAA8B,EAAkB,MAAM,sBAAsB,CAAA;AACrF,OAAO,KAAK,MAAM,OAAO,CAAA;AAEzB,MAAM,CAAC,MAAM,kBAAkB,GAAG,KAAK,CAAC,eAAe,CAAA;AAEvD,MAAM,CAAC,MAAM,YAAY,GAAG,GAAc,EAAE,CAC1C,KAAK,CAAC,OAAO,CAAC,GAAG,EAAE;IACjB,KAAK,CAAC,eAAe,GAAG,EAAE,CAAA;IAE1B,MAAM,KAAK,GAAG,IAAI,KAAK,EAAE,CAAC,KAAM,CAAA;IAChC,KAAK,CAAC,eAAe,GAAG,kBAAkB,CAAA;IAC1C,OAAO,8BAA8B,CAAC,KAAK,CAAC,CAAA;AAC9C,CAAC,EAAE,EAAE,CAAC,CAAA"}
package/src/ambient.d.ts DELETED
@@ -1 +0,0 @@
1
- var __debugLiveStore: any
@@ -1,13 +0,0 @@
1
- import { extractStackInfoFromStackTrace, type StackInfo } from '@livestore/livestore'
2
- import React from 'react'
3
-
4
- export const originalStackLimit = Error.stackTraceLimit
5
-
6
- export const useStackInfo = (): StackInfo =>
7
- React.useMemo(() => {
8
- Error.stackTraceLimit = 10
9
-
10
- const stack = new Error().stack!
11
- Error.stackTraceLimit = originalStackLimit
12
- return extractStackInfoFromStackTrace(stack)
13
- }, [])