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

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 (40) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/LiveStoreContext.d.ts +27 -0
  3. package/dist/LiveStoreContext.d.ts.map +1 -1
  4. package/dist/LiveStoreContext.js +18 -0
  5. package/dist/LiveStoreContext.js.map +1 -1
  6. package/dist/LiveStoreProvider.d.ts +9 -2
  7. package/dist/LiveStoreProvider.d.ts.map +1 -1
  8. package/dist/LiveStoreProvider.js +2 -1
  9. package/dist/LiveStoreProvider.js.map +1 -1
  10. package/dist/experimental/multi-store/StoreRegistry.d.ts +60 -16
  11. package/dist/experimental/multi-store/StoreRegistry.d.ts.map +1 -1
  12. package/dist/experimental/multi-store/StoreRegistry.js +125 -216
  13. package/dist/experimental/multi-store/StoreRegistry.js.map +1 -1
  14. package/dist/experimental/multi-store/StoreRegistry.test.js +224 -307
  15. package/dist/experimental/multi-store/StoreRegistry.test.js.map +1 -1
  16. package/dist/experimental/multi-store/types.d.ts +4 -23
  17. package/dist/experimental/multi-store/types.d.ts.map +1 -1
  18. package/dist/experimental/multi-store/useStore.d.ts +1 -1
  19. package/dist/experimental/multi-store/useStore.d.ts.map +1 -1
  20. package/dist/experimental/multi-store/useStore.js +5 -10
  21. package/dist/experimental/multi-store/useStore.js.map +1 -1
  22. package/dist/experimental/multi-store/useStore.test.js +95 -41
  23. package/dist/experimental/multi-store/useStore.test.js.map +1 -1
  24. package/dist/useClientDocument.d.ts +33 -0
  25. package/dist/useClientDocument.d.ts.map +1 -1
  26. package/dist/useClientDocument.js.map +1 -1
  27. package/dist/useStore.d.ts +51 -0
  28. package/dist/useStore.d.ts.map +1 -1
  29. package/dist/useStore.js +51 -0
  30. package/dist/useStore.js.map +1 -1
  31. package/package.json +6 -6
  32. package/src/LiveStoreContext.ts +27 -0
  33. package/src/LiveStoreProvider.tsx +9 -0
  34. package/src/experimental/multi-store/StoreRegistry.test.ts +236 -349
  35. package/src/experimental/multi-store/StoreRegistry.ts +171 -265
  36. package/src/experimental/multi-store/types.ts +31 -49
  37. package/src/experimental/multi-store/useStore.test.tsx +120 -48
  38. package/src/experimental/multi-store/useStore.ts +5 -13
  39. package/src/useClientDocument.ts +35 -0
  40. package/src/useStore.ts +51 -0
@@ -1,9 +1,10 @@
1
1
  import { makeInMemoryAdapter } from '@livestore/adapter-web'
2
2
  import type { Store } from '@livestore/livestore'
3
3
  import { StoreInternalsSymbol } from '@livestore/livestore'
4
- import { type RenderResult, render, renderHook, waitFor } from '@testing-library/react'
4
+ import { shouldNeverHappen } from '@livestore/utils'
5
+ import { act, type RenderHookResult, type RenderResult, render, renderHook, waitFor } from '@testing-library/react'
5
6
  import * as React from 'react'
6
- import { afterEach, describe, expect, it, vi } from 'vitest'
7
+ import { describe, expect, it } from 'vitest'
7
8
  import { schema } from '../../__tests__/fixture.tsx'
8
9
  import { StoreRegistry } from './StoreRegistry.ts'
9
10
  import { StoreRegistryProvider } from './StoreRegistryContext.tsx'
@@ -12,31 +13,48 @@ import type { CachedStoreOptions } from './types.ts'
12
13
  import { useStore } from './useStore.ts'
13
14
 
14
15
  describe('experimental useStore', () => {
15
- afterEach(() => {
16
- vi.clearAllTimers()
17
- vi.useRealTimers()
16
+ it('should return the same promise instance for concurrent getOrLoadStore calls', async () => {
17
+ const registry = new StoreRegistry()
18
+ const options = testStoreOptions()
19
+
20
+ // Make two concurrent calls during loading
21
+ const firstStore = registry.getOrLoadPromise(options)
22
+ const secondStore = registry.getOrLoadPromise(options)
23
+
24
+ // Both should be promises (store is loading)
25
+ expect(firstStore).toBeInstanceOf(Promise)
26
+ expect(secondStore).toBeInstanceOf(Promise)
27
+
28
+ // EXPECTED BEHAVIOR: Same promise instance for React.use() compatibility
29
+ // ACTUAL BEHAVIOR: Different promise instances (Effect.runPromise creates new wrapper)
30
+ expect(firstStore).toBe(secondStore)
31
+
32
+ // Cleanup
33
+ await firstStore
34
+ await cleanupAfterUnmount(() => {})
18
35
  })
19
36
 
20
- it('suspends when the store is loading', async () => {
37
+ it('works with Suspense boundary', async () => {
21
38
  const registry = new StoreRegistry()
22
39
  const options = testStoreOptions()
23
40
 
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()
41
+ let view: RenderResult | undefined
42
+ await act(async () => {
43
+ view = render(
44
+ <StoreRegistryProvider storeRegistry={registry}>
45
+ <React.Suspense fallback={<div data-testid="fallback" />}>
46
+ <StoreConsumer options={options} />
47
+ </React.Suspense>
48
+ </StoreRegistryProvider>,
49
+ )
50
+ })
51
+ const renderedView = view ?? shouldNeverHappen('render failed')
34
52
 
35
- // Wait for store to load and component to render
36
- await waitForSuspenseResolved(view)
37
- expect(view.getByTestId('ready')).toBeDefined()
53
+ // After loading completes, should show the actual content
54
+ await waitForSuspenseResolved(renderedView)
55
+ expect(renderedView.getByTestId('ready')).toBeDefined()
38
56
 
39
- cleanupWithPendingTimers(() => view.unmount())
57
+ await cleanupAfterUnmount(() => renderedView.unmount())
40
58
  })
41
59
 
42
60
  it('does not re-suspend on subsequent renders when store is already loaded', async () => {
@@ -51,20 +69,26 @@ describe('experimental useStore', () => {
51
69
  </StoreRegistryProvider>
52
70
  )
53
71
 
54
- const view = render(<Wrapper opts={options} />)
72
+ let view: RenderResult | undefined
73
+ await act(async () => {
74
+ view = render(<Wrapper opts={options} />)
75
+ })
76
+ const renderedView = view ?? shouldNeverHappen('render failed')
55
77
 
56
78
  // Wait for initial load
57
- await waitForSuspenseResolved(view)
58
- expect(view.getByTestId('ready')).toBeDefined()
79
+ await waitForSuspenseResolved(renderedView)
80
+ expect(renderedView.getByTestId('ready')).toBeDefined()
59
81
 
60
82
  // Rerender with new options object (but same storeId)
61
- view.rerender(<Wrapper opts={{ ...options }} />)
83
+ await act(async () => {
84
+ renderedView.rerender(<Wrapper opts={{ ...options }} />)
85
+ })
62
86
 
63
87
  // Should not show fallback
64
- expect(view.queryByTestId('fallback')).toBeNull()
65
- expect(view.getByTestId('ready')).toBeDefined()
88
+ expect(renderedView.queryByTestId('fallback')).toBeNull()
89
+ expect(renderedView.getByTestId('ready')).toBeDefined()
66
90
 
67
- cleanupWithPendingTimers(() => view.unmount())
91
+ await cleanupAfterUnmount(() => renderedView.unmount())
68
92
  })
69
93
 
70
94
  it('throws when store loading fails', async () => {
@@ -74,8 +98,8 @@ describe('experimental useStore', () => {
74
98
  adapter: null,
75
99
  })
76
100
 
77
- // Pre-load the store to cache the error
78
- await expect(registry.getOrLoad(badOptions)).rejects.toThrow()
101
+ // Pre-load the store to cache the error (error happens synchronously)
102
+ expect(() => registry.getOrLoadPromise(badOptions)).toThrow()
79
103
 
80
104
  // Now when useStore tries to get it, it should throw synchronously
81
105
  expect(() =>
@@ -92,16 +116,20 @@ describe('experimental useStore', () => {
92
116
  const registry = new StoreRegistry()
93
117
  const options = testStoreOptions()
94
118
 
95
- const { result, unmount } = renderHook(() => useStore(options), {
96
- wrapper: makeProvider(registry, { suspense: true }),
97
- reactStrictMode: strictMode,
119
+ let hook: RenderHookResult<Store<typeof schema>, CachedStoreOptions<typeof schema>> | undefined
120
+ await act(async () => {
121
+ hook = renderHook(() => useStore(options), {
122
+ wrapper: makeProvider(registry, { suspense: true }),
123
+ reactStrictMode: strictMode,
124
+ })
98
125
  })
126
+ const { result, unmount } = hook ?? shouldNeverHappen('renderHook failed')
99
127
 
100
128
  // Wait for store to be ready
101
129
  await waitForStoreReady(result)
102
130
  expect(result.current[StoreInternalsSymbol].clientSession).toBeDefined()
103
131
 
104
- cleanupWithPendingTimers(unmount)
132
+ await cleanupAfterUnmount(unmount)
105
133
  })
106
134
 
107
135
  it('handles switching between different storeId values', async () => {
@@ -110,10 +138,14 @@ describe('experimental useStore', () => {
110
138
  const optionsA = testStoreOptions({ storeId: 'store-a' })
111
139
  const optionsB = testStoreOptions({ storeId: 'store-b' })
112
140
 
113
- const { result, rerender, unmount } = renderHook((opts) => useStore(opts), {
114
- initialProps: optionsA,
115
- wrapper: makeProvider(registry, { suspense: true }),
141
+ let hook: RenderHookResult<Store<typeof schema>, CachedStoreOptions<typeof schema>> | undefined
142
+ await act(async () => {
143
+ hook = renderHook((opts) => useStore(opts), {
144
+ initialProps: optionsA,
145
+ wrapper: makeProvider(registry, { suspense: true }),
146
+ })
116
147
  })
148
+ const { result, rerender, unmount } = hook ?? shouldNeverHappen('renderHook failed')
117
149
 
118
150
  // Wait for first store to load
119
151
  await waitForStoreReady(result)
@@ -121,7 +153,9 @@ describe('experimental useStore', () => {
121
153
  expect(storeA[StoreInternalsSymbol].clientSession).toBeDefined()
122
154
 
123
155
  // Switch to different storeId
124
- rerender(optionsB)
156
+ await act(async () => {
157
+ rerender(optionsB)
158
+ })
125
159
 
126
160
  // Wait for second store to load and verify it's different from the first
127
161
  await waitFor(() => {
@@ -133,7 +167,47 @@ describe('experimental useStore', () => {
133
167
  expect(storeB[StoreInternalsSymbol].clientSession).toBeDefined()
134
168
  expect(storeB).not.toBe(storeA)
135
169
 
136
- cleanupWithPendingTimers(unmount)
170
+ await cleanupAfterUnmount(unmount)
171
+ })
172
+
173
+ // useStore doesn't handle unusedCacheTime=0 correctly because retain is called in useEffect (after render)
174
+ // See https://github.com/livestorejs/livestore/issues/916
175
+ it.skip('should load store with unusedCacheTime set to 0', async () => {
176
+ const registry = new StoreRegistry({ defaultOptions: { unusedCacheTime: 0 } })
177
+ const options = testStoreOptions({ unusedCacheTime: 0 })
178
+
179
+ const StoreConsumerWithVerification = ({ opts }: { opts: CachedStoreOptions<typeof schema> }) => {
180
+ const store = useStore(opts)
181
+ // Verify store is usable - access internals to confirm it's not disposed
182
+ const clientSession = store[StoreInternalsSymbol].clientSession
183
+ return <div data-testid="ready" data-has-session={String(clientSession !== undefined)} />
184
+ }
185
+
186
+ let view: RenderResult | undefined
187
+ await act(async () => {
188
+ view = render(
189
+ <StoreRegistryProvider storeRegistry={registry}>
190
+ <React.Suspense fallback={<div data-testid="fallback" />}>
191
+ <StoreConsumerWithVerification opts={options} />
192
+ </React.Suspense>
193
+ </StoreRegistryProvider>,
194
+ )
195
+ })
196
+ const renderedView = view ?? shouldNeverHappen('render failed')
197
+
198
+ await waitForSuspenseResolved(renderedView)
199
+
200
+ // Store should be usable while component is mounted
201
+ const readyElement = renderedView.getByTestId('ready')
202
+ expect(readyElement.getAttribute('data-has-session')).toBe('true')
203
+
204
+ // Allow some time to pass to ensure store isn't prematurely disposed
205
+ await new Promise((resolve) => setTimeout(resolve, 50))
206
+
207
+ // Store should still be usable after waiting
208
+ expect(readyElement.getAttribute('data-has-session')).toBe('true')
209
+
210
+ await cleanupAfterUnmount(() => renderedView.unmount())
137
211
  })
138
212
  })
139
213
 
@@ -154,28 +228,26 @@ const makeProvider =
154
228
  return content
155
229
  }
156
230
 
231
+ let testStoreCounter = 0
232
+
157
233
  const testStoreOptions = (overrides: Partial<CachedStoreOptions<typeof schema>> = {}) =>
158
234
  storeOptions({
159
- storeId: 'test-store',
235
+ storeId: overrides.storeId ?? `test-store-${testStoreCounter++}`,
160
236
  schema,
161
237
  adapter: makeInMemoryAdapter(),
162
238
  ...overrides,
163
239
  })
164
240
 
165
241
  /**
166
- * Cleans up after component unmount by synchronously executing any pending GC timers.
242
+ * Cleans up after component unmount and waits for pending operations to settle.
167
243
  *
168
244
  * 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.
245
+ * timers for inactive stores. This helper waits for those timers to complete naturally.
174
246
  */
175
- const cleanupWithPendingTimers = (cleanup: () => void): void => {
176
- vi.useFakeTimers()
247
+ const cleanupAfterUnmount = async (cleanup: () => void): Promise<void> => {
177
248
  cleanup()
178
- vi.runOnlyPendingTimers()
249
+ // Allow any pending microtasks/timers to settle
250
+ await new Promise((resolve) => setTimeout(resolve, 100))
179
251
  }
180
252
 
181
253
  /**
@@ -7,7 +7,7 @@ import { useStoreRegistry } from './StoreRegistryContext.tsx'
7
7
  import type { CachedStoreOptions } from './types.ts'
8
8
 
9
9
  /**
10
- * Suspense + Error Boundary friendly hook.
10
+ * Suspense and Error Boundary friendly hook.
11
11
  * - Returns data or throws (Promise|Error).
12
12
  * - No loading or error states are returned.
13
13
  */
@@ -16,19 +16,11 @@ export const useStore = <TSchema extends LiveStoreSchema>(
16
16
  ): Store<TSchema> & ReactApi => {
17
17
  const storeRegistry = useStoreRegistry()
18
18
 
19
- const subscribe = React.useCallback(
20
- (onChange: () => void) => storeRegistry.subscribe(options.storeId, onChange),
21
- [storeRegistry, options.storeId],
22
- )
23
- const getSnapshot = React.useCallback(() => {
24
- const storeOrPromise = storeRegistry.getOrLoad(options)
19
+ React.useEffect(() => storeRegistry.retain(options), [storeRegistry, options])
25
20
 
26
- if (storeOrPromise instanceof Promise) throw storeOrPromise
21
+ const storeOrPromise = React.useMemo(() => storeRegistry.getOrLoadPromise(options), [storeRegistry, options])
27
22
 
28
- return storeOrPromise
29
- }, [storeRegistry, options])
23
+ const store = storeOrPromise instanceof Promise ? React.use(storeOrPromise) : storeOrPromise
30
24
 
31
- const loadedStore = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
32
-
33
- return withReactApi(loadedStore)
25
+ return withReactApi(store)
34
26
  }
@@ -9,6 +9,17 @@ import React from 'react'
9
9
  import { LiveStoreContext } from './LiveStoreContext.ts'
10
10
  import { useQueryRef } from './useQuery.ts'
11
11
 
12
+ /**
13
+ * Return type of `useClientDocument` hook.
14
+ *
15
+ * A tuple providing React-style state access to a client-document table row:
16
+ * - `[0]` row: The current value (decoded according to the table schema)
17
+ * - `[1]` setRow: Setter function to update the document
18
+ * - `[2]` id: The document's ID (resolved from `SessionIdSymbol` if applicable)
19
+ * - `[3]` query$: The underlying `LiveQuery` for advanced use cases
20
+ *
21
+ * @typeParam TTableDef - The client-document table definition type
22
+ */
12
23
  export type UseClientDocumentResult<TTableDef extends State.SQLite.ClientDocumentTableDef.TraitAny> = [
13
24
  row: TTableDef['Value'],
14
25
  setRow: StateSetters<TTableDef>,
@@ -140,10 +151,34 @@ export const useClientDocument: {
140
151
  return [queryRef.valueRef.current, setState, idStr, queryRef.queryRcRef.value]
141
152
  }
142
153
 
154
+ /**
155
+ * A function that dispatches an action. Mirrors React's `Dispatch` type.
156
+ * @typeParam A - The action type
157
+ */
143
158
  export type Dispatch<A> = (action: A) => void
159
+
160
+ /**
161
+ * A state update that can be either a partial value or a function returning a partial value.
162
+ * Used when the client-document table has `partialSet: true`.
163
+ * @typeParam S - The state type
164
+ */
144
165
  export type SetStateActionPartial<S> = Partial<S> | ((previousValue: S) => Partial<S>)
166
+
167
+ /**
168
+ * A state update that can be either a full value or a function returning a full value.
169
+ * Mirrors React's `SetStateAction` type.
170
+ * @typeParam S - The state type
171
+ */
145
172
  export type SetStateAction<S> = S | ((previousValue: S) => S)
146
173
 
174
+ /**
175
+ * The setter function type for `useClientDocument`, determined by the table's `partialSet` option.
176
+ *
177
+ * - If `partialSet: false` (default), requires full state replacement
178
+ * - If `partialSet: true`, accepts partial updates merged with existing state
179
+ *
180
+ * @typeParam TTableDef - The client-document table definition type
181
+ */
147
182
  export type StateSetters<TTableDef extends State.SQLite.ClientDocumentTableDef.TraitAny> = Dispatch<
148
183
  TTableDef[State.SQLite.ClientDocumentTableDefSymbol]['options']['partialSet'] extends false
149
184
  ? SetStateAction<TTableDef['Value']>
package/src/useStore.ts CHANGED
@@ -7,6 +7,19 @@ import { LiveStoreContext } from './LiveStoreContext.ts'
7
7
  import { useClientDocument } from './useClientDocument.ts'
8
8
  import { useQuery } from './useQuery.ts'
9
9
 
10
+ /**
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.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * // Usually not needed—useStore() does this automatically
19
+ * const store = withReactApi(myStore)
20
+ * const todos = store.useQuery(tables.todos.all())
21
+ * ```
22
+ */
10
23
  export const withReactApi = <TSchema extends LiveStoreSchema>(store: Store<TSchema>): Store<TSchema> & ReactApi => {
11
24
  // @ts-expect-error TODO properly implement this
12
25
 
@@ -17,6 +30,44 @@ export const withReactApi = <TSchema extends LiveStoreSchema>(store: Store<TSche
17
30
  return store as Store<TSchema> & ReactApi
18
31
  }
19
32
 
33
+ /**
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
+ * ```
54
+ *
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
+ * ```
62
+ *
63
+ * @example
64
+ * ```ts
65
+ * // Use with an explicit store instance (bypasses context)
66
+ * const { store } = useStore({ store: myExternalStore })
67
+ * ```
68
+ *
69
+ * @throws Error if called outside of `<LiveStoreProvider>` or before the store is running
70
+ */
20
71
  export const useStore = (options?: { store?: Store }): { store: Store & ReactApi } => {
21
72
  if (options?.store !== undefined) {
22
73
  return { store: withReactApi(options.store) }