@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.
- package/README.md +1 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/StoreRegistryContext.d.ts +1 -1
- package/dist/StoreRegistryContext.d.ts.map +1 -1
- package/dist/StoreRegistryContext.js +2 -2
- package/dist/StoreRegistryContext.js.map +1 -1
- package/dist/__tests__/fixture.d.ts +8 -280
- package/dist/__tests__/fixture.d.ts.map +1 -1
- package/dist/__tests__/fixture.js +8 -78
- package/dist/__tests__/fixture.js.map +1 -1
- package/dist/experimental/components/LiveList.d.ts.map +1 -1
- package/dist/experimental/components/LiveList.js +5 -4
- package/dist/experimental/components/LiveList.js.map +1 -1
- package/dist/mod.d.ts +4 -2
- package/dist/mod.d.ts.map +1 -1
- package/dist/mod.js +3 -2
- package/dist/mod.js.map +1 -1
- package/dist/useClientDocument.d.ts +1 -26
- package/dist/useClientDocument.d.ts.map +1 -1
- package/dist/useClientDocument.js +2 -13
- package/dist/useClientDocument.js.map +1 -1
- package/dist/useClientDocument.test.js +12 -4
- package/dist/useClientDocument.test.js.map +1 -1
- package/dist/useQuery.d.ts +3 -4
- package/dist/useQuery.d.ts.map +1 -1
- package/dist/useQuery.js +10 -80
- package/dist/useQuery.js.map +1 -1
- package/dist/useQuery.test.js +7 -8
- package/dist/useQuery.test.js.map +1 -1
- package/dist/useRcResource.d.ts.map +1 -1
- package/dist/useRcResource.js +9 -5
- package/dist/useRcResource.js.map +1 -1
- package/dist/useRcResource.test.js +1 -1
- package/dist/useRcResource.test.js.map +1 -1
- package/dist/useStore.d.ts +12 -1
- package/dist/useStore.d.ts.map +1 -1
- package/dist/useStore.js +21 -13
- package/dist/useStore.js.map +1 -1
- package/dist/useStore.test.js +53 -8
- package/dist/useStore.test.js.map +1 -1
- package/dist/useSyncStatus.d.ts +22 -0
- package/dist/useSyncStatus.d.ts.map +1 -0
- package/dist/useSyncStatus.js +28 -0
- package/dist/useSyncStatus.js.map +1 -0
- package/package.json +68 -25
- package/src/StoreRegistryContext.tsx +4 -3
- package/src/__snapshots__/useClientDocument.test.tsx.snap +112 -78
- package/src/__tests__/fixture.tsx +22 -105
- package/src/experimental/components/LiveList.tsx +9 -5
- package/src/mod.ts +4 -9
- package/src/useClientDocument.test.tsx +16 -6
- package/src/useClientDocument.ts +6 -56
- package/src/useQuery.test.tsx +8 -8
- package/src/useQuery.ts +28 -113
- package/src/useRcResource.test.tsx +1 -1
- package/src/useRcResource.ts +10 -5
- package/src/useStore.test.tsx +85 -9
- package/src/useStore.ts +30 -17
- package/src/useSyncStatus.ts +34 -0
- package/dist/utils/stack-info.d.ts +0 -4
- package/dist/utils/stack-info.d.ts.map +0 -1
- package/dist/utils/stack-info.js +0 -10
- package/dist/utils/stack-info.js.map +0 -1
- package/src/ambient.d.ts +0 -1
- package/src/utils/stack-info.ts +0 -13
package/src/useStore.test.tsx
CHANGED
|
@@ -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={
|
|
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={
|
|
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={
|
|
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={
|
|
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
|
-
|
|
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
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
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
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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 +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"}
|
package/dist/utils/stack-info.js
DELETED
|
@@ -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
|
package/src/utils/stack-info.ts
DELETED
|
@@ -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
|
-
}, [])
|