@livestore/react 0.4.0-dev.8 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/StoreRegistryContext.d.ts +56 -0
- package/dist/StoreRegistryContext.d.ts.map +1 -0
- package/dist/StoreRegistryContext.js +61 -0
- package/dist/StoreRegistryContext.js.map +1 -0
- package/dist/__tests__/fixture.d.ts +12 -283
- package/dist/__tests__/fixture.d.ts.map +1 -1
- package/dist/__tests__/fixture.js +12 -86
- package/dist/__tests__/fixture.js.map +1 -1
- package/dist/experimental/components/LiveList.d.ts +4 -2
- package/dist/experimental/components/LiveList.d.ts.map +1 -1
- package/dist/experimental/components/LiveList.js +10 -8
- package/dist/experimental/components/LiveList.js.map +1 -1
- package/dist/mod.d.ts +8 -5
- package/dist/mod.d.ts.map +1 -1
- package/dist/mod.js +6 -4
- package/dist/mod.js.map +1 -1
- package/dist/useClientDocument.d.ts +12 -4
- package/dist/useClientDocument.d.ts.map +1 -1
- package/dist/useClientDocument.js +4 -18
- package/dist/useClientDocument.js.map +1 -1
- package/dist/useClientDocument.test.js +21 -9
- package/dist/useClientDocument.test.js.map +1 -1
- package/dist/useQuery.d.ts +29 -8
- package/dist/useQuery.d.ts.map +1 -1
- package/dist/useQuery.js +43 -70
- package/dist/useQuery.js.map +1 -1
- package/dist/useQuery.test.js +57 -13
- package/dist/useQuery.test.js.map +1 -1
- package/dist/useRcResource.d.ts +1 -1
- package/dist/useRcResource.d.ts.map +1 -1
- package/dist/useRcResource.js +41 -34
- package/dist/useRcResource.js.map +1 -1
- package/dist/useRcResource.test.js +72 -50
- package/dist/useRcResource.test.js.map +1 -1
- package/dist/useStore.d.ts +74 -7
- package/dist/useStore.d.ts.map +1 -1
- package/dist/useStore.js +82 -16
- package/dist/useStore.js.map +1 -1
- package/dist/useStore.test.d.ts +2 -0
- package/dist/useStore.test.d.ts.map +1 -0
- package/dist/useStore.test.js +241 -0
- package/dist/useStore.test.js.map +1 -0
- 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 +70 -0
- package/src/__snapshots__/useClientDocument.test.tsx.snap +112 -78
- package/src/__snapshots__/useQuery.test.tsx.snap +12 -12
- package/src/__tests__/fixture.tsx +29 -133
- package/src/experimental/components/LiveList.tsx +23 -10
- package/src/mod.ts +8 -12
- package/src/useClientDocument.test.tsx +90 -79
- package/src/useClientDocument.ts +19 -38
- package/src/useQuery.test.tsx +99 -13
- package/src/useQuery.ts +74 -89
- package/src/useRcResource.test.tsx +115 -59
- package/src/useRcResource.ts +51 -37
- package/src/useStore.test.tsx +347 -0
- package/src/useStore.ts +115 -22
- package/src/useSyncStatus.ts +34 -0
- package/dist/LiveStoreContext.d.ts +0 -13
- package/dist/LiveStoreContext.d.ts.map +0 -1
- package/dist/LiveStoreContext.js +0 -3
- package/dist/LiveStoreContext.js.map +0 -1
- package/dist/LiveStoreProvider.d.ts +0 -65
- package/dist/LiveStoreProvider.d.ts.map +0 -1
- package/dist/LiveStoreProvider.js +0 -221
- package/dist/LiveStoreProvider.js.map +0 -1
- package/dist/LiveStoreProvider.test.d.ts +0 -2
- package/dist/LiveStoreProvider.test.d.ts.map +0 -1
- package/dist/LiveStoreProvider.test.js +0 -117
- package/dist/LiveStoreProvider.test.js.map +0 -1
- 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/LiveStoreContext.ts +0 -14
- package/src/LiveStoreProvider.test.tsx +0 -248
- package/src/LiveStoreProvider.tsx +0 -413
- package/src/ambient.d.ts +0 -1
- package/src/utils/stack-info.ts +0 -13
package/src/useQuery.test.tsx
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
/** biome-ignore-all lint/a11y: test */
|
|
2
|
+
import { makeInMemoryAdapter } from '@livestore/adapter-web'
|
|
3
|
+
import { provideOtel } from '@livestore/common'
|
|
2
4
|
import * as LiveStore from '@livestore/livestore'
|
|
3
|
-
import { queryDb, signal } from '@livestore/livestore'
|
|
5
|
+
import { createStore, queryDb, StoreInternalsSymbol, signal } from '@livestore/livestore'
|
|
4
6
|
import { RG } from '@livestore/livestore/internal/testing-utils'
|
|
5
7
|
import { Effect, Schema } from '@livestore/utils/effect'
|
|
6
8
|
import { Vitest } from '@livestore/utils-dev/node-vitest'
|
|
7
9
|
import * as ReactTesting from '@testing-library/react'
|
|
8
10
|
import React from 'react'
|
|
9
|
-
// @ts-expect-error no types
|
|
10
11
|
import * as ReactWindow from 'react-window'
|
|
11
12
|
import { expect } from 'vitest'
|
|
12
13
|
|
|
13
|
-
import { events, makeTodoMvcReact, tables } from './__tests__/fixture.
|
|
14
|
-
import { __resetUseRcResourceCache } from './useRcResource.
|
|
14
|
+
import { events, makeTodoMvcReact, schema, tables } from './__tests__/fixture.tsx'
|
|
15
|
+
import { __resetUseRcResourceCache } from './useRcResource.ts'
|
|
16
|
+
import { withReactApi } from './useStore.ts'
|
|
15
17
|
|
|
16
18
|
Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
|
|
17
19
|
'useQuery (strictMode=%s)',
|
|
@@ -38,14 +40,14 @@ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
|
|
|
38
40
|
|
|
39
41
|
expect(result.current.length).toBe(0)
|
|
40
42
|
expect(renderCount.val).toBe(1)
|
|
41
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
43
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
42
44
|
|
|
43
45
|
ReactTesting.act(() => store.commit(events.todoCreated({ id: 't1', text: 'buy milk', completed: false })))
|
|
44
46
|
|
|
45
47
|
expect(result.current.length).toBe(1)
|
|
46
48
|
expect(result.current[0]!.text).toBe('buy milk')
|
|
47
49
|
expect(renderCount.val).toBe(2)
|
|
48
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
50
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
49
51
|
}),
|
|
50
52
|
)
|
|
51
53
|
|
|
@@ -80,19 +82,25 @@ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
|
|
|
80
82
|
|
|
81
83
|
expect(result.current).toBe('buy milk')
|
|
82
84
|
expect(renderCount.val).toBe(1)
|
|
83
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
|
|
85
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
|
|
86
|
+
'1: after first render',
|
|
87
|
+
)
|
|
84
88
|
|
|
85
89
|
ReactTesting.act(() => store.commit(events.todoUpdated({ id: 't1', text: 'buy soy milk' })))
|
|
86
90
|
|
|
87
91
|
expect(result.current).toBe('buy soy milk')
|
|
88
92
|
expect(renderCount.val).toBe(2)
|
|
89
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
|
|
93
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
|
|
94
|
+
'2: after first commit',
|
|
95
|
+
)
|
|
90
96
|
|
|
91
97
|
rerender('t2')
|
|
92
98
|
|
|
93
99
|
expect(result.current).toBe('buy eggs')
|
|
94
100
|
expect(renderCount.val).toBe(3)
|
|
95
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
|
|
101
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
|
|
102
|
+
'3: after forced rerender',
|
|
103
|
+
)
|
|
96
104
|
}),
|
|
97
105
|
)
|
|
98
106
|
|
|
@@ -120,19 +128,19 @@ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
|
|
|
120
128
|
|
|
121
129
|
expect(result.current).toBe('buy milk')
|
|
122
130
|
expect(renderCount.val).toBe(1)
|
|
123
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
131
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
124
132
|
|
|
125
133
|
ReactTesting.act(() => store.commit(events.todoUpdated({ id: 't1', text: 'buy soy milk' })))
|
|
126
134
|
|
|
127
135
|
expect(result.current).toBe('buy soy milk')
|
|
128
136
|
expect(renderCount.val).toBe(2)
|
|
129
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
137
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
130
138
|
|
|
131
139
|
ReactTesting.act(() => store.setSignal(filter$, 't2'))
|
|
132
140
|
|
|
133
141
|
expect(result.current).toBe('buy eggs')
|
|
134
142
|
expect(renderCount.val).toBe(3)
|
|
135
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
143
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
136
144
|
}),
|
|
137
145
|
)
|
|
138
146
|
|
|
@@ -150,7 +158,7 @@ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
|
|
|
150
158
|
width={100}
|
|
151
159
|
itemSize={10}
|
|
152
160
|
itemCount={numItems}
|
|
153
|
-
itemData={Array.from({ length: numItems }, (_, i) => i).
|
|
161
|
+
itemData={Array.from({ length: numItems }, (_, i) => i).toReversed()}
|
|
154
162
|
>
|
|
155
163
|
{ListItem}
|
|
156
164
|
</ReactWindow.FixedSizeList>
|
|
@@ -187,5 +195,83 @@ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
|
|
|
187
195
|
expect(result.current).toBe(1)
|
|
188
196
|
}),
|
|
189
197
|
)
|
|
198
|
+
|
|
199
|
+
Vitest.scopedLive('supports query builders directly', () =>
|
|
200
|
+
Effect.gen(function* () {
|
|
201
|
+
const { wrapper, store } = yield* makeTodoMvcReact({ strictMode })
|
|
202
|
+
|
|
203
|
+
store.commit(
|
|
204
|
+
events.todoCreated({ id: 't1', text: 'buy milk', completed: false }),
|
|
205
|
+
events.todoCreated({ id: 't2', text: 'buy eggs', completed: true }),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
const todosWhereIncomplete = tables.todos.where({ completed: false })
|
|
209
|
+
|
|
210
|
+
const { result } = ReactTesting.renderHook(() => store.useQuery(todosWhereIncomplete).map((todo) => todo.id), {
|
|
211
|
+
wrapper,
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
expect(result.current).toEqual(['t1'])
|
|
215
|
+
}),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
Vitest.scopedLive('derives a fresh LiveQuery per Store instance', () =>
|
|
219
|
+
Effect.gen(function* () {
|
|
220
|
+
const makeStore = () =>
|
|
221
|
+
createStore({
|
|
222
|
+
schema,
|
|
223
|
+
storeId: 'reset-test',
|
|
224
|
+
adapter: makeInMemoryAdapter({ clientId: 'stable-client', sessionId: 'stable-session' }),
|
|
225
|
+
debug: { instanceId: 'test' },
|
|
226
|
+
}).pipe(provideOtel({}))
|
|
227
|
+
|
|
228
|
+
const storeA = withReactApi(yield* makeStore())
|
|
229
|
+
const storeB = withReactApi(yield* makeStore())
|
|
230
|
+
|
|
231
|
+
const allTodos$ = queryDb({ query: `select * from todos`, schema: Schema.Array(tables.todos.rowSchema) })
|
|
232
|
+
|
|
233
|
+
const MaybeStrictMode = strictMode === true ? React.StrictMode : React.Fragment
|
|
234
|
+
const wrapper = ({ children }: any) => <MaybeStrictMode>{children}</MaybeStrictMode>
|
|
235
|
+
|
|
236
|
+
storeA.commit(events.todoCreated({ id: 't1', text: 'buy milk', completed: false }))
|
|
237
|
+
|
|
238
|
+
const { result, rerender } = ReactTesting.renderHook(
|
|
239
|
+
({ store }: { store: typeof storeA }) => store.useQuery(allTodos$).length,
|
|
240
|
+
{ wrapper, initialProps: { store: storeA } },
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
expect(result.current).toBe(1)
|
|
244
|
+
|
|
245
|
+
rerender({ store: storeB })
|
|
246
|
+
|
|
247
|
+
expect(result.current).toBe(0)
|
|
248
|
+
}),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
Vitest.scopedLive('union of different result types with useQuery', () =>
|
|
252
|
+
Effect.gen(function* () {
|
|
253
|
+
const { wrapper, store, renderCount } = yield* makeTodoMvcReact({ strictMode })
|
|
254
|
+
|
|
255
|
+
const str$ = signal('hello', { label: 'str' })
|
|
256
|
+
const num$ = signal(123, { label: 'num' })
|
|
257
|
+
|
|
258
|
+
const { result, rerender } = ReactTesting.renderHook(
|
|
259
|
+
(useNum: boolean) => {
|
|
260
|
+
renderCount.inc()
|
|
261
|
+
const query$ = React.useMemo(() => (useNum === true ? num$ : str$), [useNum])
|
|
262
|
+
return store.useQuery(query$)
|
|
263
|
+
},
|
|
264
|
+
{ wrapper, initialProps: false },
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
expect(result.current).toBe('hello')
|
|
268
|
+
expect(renderCount.val).toBe(1)
|
|
269
|
+
|
|
270
|
+
rerender(true)
|
|
271
|
+
|
|
272
|
+
expect(result.current).toBe(123)
|
|
273
|
+
expect(renderCount.val).toBe(2)
|
|
274
|
+
}),
|
|
275
|
+
)
|
|
190
276
|
},
|
|
191
277
|
)
|
package/src/useQuery.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { extractStackInfoFromStackTrace, stackInfoToString } from '@livestore/livestore'
|
|
3
|
-
import type { LiveQueries } from '@livestore/livestore/internal'
|
|
4
|
-
import { deepEqual, indent, shouldNeverHappen } from '@livestore/utils'
|
|
5
|
-
import * as otel from '@opentelemetry/api'
|
|
1
|
+
import type * as otel from '@opentelemetry/api'
|
|
6
2
|
import React from 'react'
|
|
7
3
|
|
|
8
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
captureStackInfo,
|
|
6
|
+
computeRcRefKey,
|
|
7
|
+
createQueryResource,
|
|
8
|
+
type NormalizedQueryable,
|
|
9
|
+
normalizeQueryable,
|
|
10
|
+
runInitialQuery,
|
|
11
|
+
} from '@livestore/framework-toolkit'
|
|
12
|
+
import type { LiveQuery, Queryable, Store } from '@livestore/livestore'
|
|
13
|
+
import type { LiveQueries } from '@livestore/livestore/internal'
|
|
14
|
+
import { deepEqual, shouldNeverHappen } from '@livestore/utils'
|
|
15
|
+
|
|
9
16
|
import { useRcResource } from './useRcResource.ts'
|
|
10
|
-
import { originalStackLimit } from './utils/stack-info.ts'
|
|
11
17
|
import { useStateRefWithReactiveInput } from './utils/useStateRefWithReactiveInput.ts'
|
|
12
18
|
|
|
13
19
|
/**
|
|
@@ -21,15 +27,36 @@ import { useStateRefWithReactiveInput } from './utils/useStateRefWithReactiveInp
|
|
|
21
27
|
* }
|
|
22
28
|
* ```
|
|
23
29
|
*/
|
|
24
|
-
export const useQuery = <
|
|
25
|
-
|
|
30
|
+
export const useQuery = <TQueryable extends Queryable<any>>(
|
|
31
|
+
queryable: TQueryable,
|
|
26
32
|
options?: { store?: Store },
|
|
27
|
-
):
|
|
33
|
+
): Queryable.Result<TQueryable> => useQueryRef(queryable, options).valueRef.current
|
|
28
34
|
|
|
29
35
|
/**
|
|
36
|
+
* Like `useQuery`, but also returns a reference to the underlying LiveQuery instance.
|
|
37
|
+
*
|
|
38
|
+
* Usage
|
|
39
|
+
* - Accepts any `Queryable<TResult>`: a `LiveQueryDef`, `SignalDef`, a `LiveQuery` instance
|
|
40
|
+
* or a SQL `QueryBuilder`. Unions of queryables are supported and the result type is
|
|
41
|
+
* inferred via `Queryable.Result<TQueryable>`.
|
|
42
|
+
* - Creates an OpenTelemetry span per unique query, reusing it while the ref-counted
|
|
43
|
+
* resource is alive. The span name is updated once the dynamic label is known.
|
|
44
|
+
* - Manages a reference-counted resource under-the-hood so query instances are shared
|
|
45
|
+
* across re-renders and properly disposed once no longer referenced.
|
|
46
|
+
*
|
|
47
|
+
* Parameters
|
|
48
|
+
* - `queryable`: The query definition/instance/builder to run and subscribe to.
|
|
49
|
+
* - `options.store`: The store to use. Required when calling `useQueryRef` directly; automatically provided when using `store.useQuery()`.
|
|
50
|
+
* - `options.otelContext`: Optional parent otel context for the query span.
|
|
51
|
+
* - `options.otelSpanName`: Optional explicit span name; otherwise derived from the query label.
|
|
52
|
+
*
|
|
53
|
+
* Returns
|
|
54
|
+
* - `valueRef`: A React ref whose `current` holds the latest query result. The type is
|
|
55
|
+
* `Queryable.Result<TQueryable>` with full inference for unions.
|
|
56
|
+
* - `queryRcRef`: The underlying reference-counted `LiveQuery` instance used by the store.
|
|
30
57
|
*/
|
|
31
|
-
export const useQueryRef = <
|
|
32
|
-
|
|
58
|
+
export const useQueryRef = <TQueryable extends Queryable<any>>(
|
|
59
|
+
queryable: TQueryable,
|
|
33
60
|
options?: {
|
|
34
61
|
store?: Store
|
|
35
62
|
/** Parent otel context for the query */
|
|
@@ -38,102 +65,58 @@ export const useQueryRef = <TQuery extends LiveQueryDef.Any>(
|
|
|
38
65
|
otelSpanName?: string
|
|
39
66
|
},
|
|
40
67
|
): {
|
|
41
|
-
valueRef: React.RefObject<
|
|
42
|
-
queryRcRef: LiveQueries.RcRef<LiveQuery<
|
|
68
|
+
valueRef: React.RefObject<Queryable.Result<TQueryable>>
|
|
69
|
+
queryRcRef: LiveQueries.RcRef<LiveQuery<Queryable.Result<TQueryable>>>
|
|
43
70
|
} => {
|
|
44
|
-
const store =
|
|
45
|
-
options?.store ?? // biome-ignore lint/correctness/useHookAtTopLevel: store is stable
|
|
46
|
-
React.useContext(LiveStoreContext)?.store ??
|
|
47
|
-
shouldNeverHappen(`No store provided to useQuery`)
|
|
48
|
-
|
|
49
|
-
// It's important to use all "aspects" of a store instance here, otherwise we get unexpected cache mappings
|
|
50
|
-
const rcRefKey = `${store.storeId}_${store.clientId}_${store.sessionId}_${queryDef.hash}`
|
|
71
|
+
const store = options?.store ?? shouldNeverHappen(`No store provided to useQuery`)
|
|
51
72
|
|
|
52
|
-
|
|
53
|
-
Error.stackTraceLimit = 10
|
|
54
|
-
const stack = new Error().stack!
|
|
55
|
-
Error.stackTraceLimit = originalStackLimit
|
|
56
|
-
return extractStackInfoFromStackTrace(stack)
|
|
57
|
-
}, [])
|
|
73
|
+
type TResult = Queryable.Result<TQueryable>
|
|
58
74
|
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const span = store.otel.tracer.startSpan(
|
|
65
|
-
options?.otelSpanName ?? `LiveStore:useQuery:${queryDefLabel}`,
|
|
66
|
-
{ attributes: { label: queryDefLabel, firstStackInfo: JSON.stringify(stackInfo) } },
|
|
67
|
-
options?.otelContext ?? store.otel.queriesSpanContext,
|
|
68
|
-
)
|
|
75
|
+
const normalized = React.useMemo<NormalizedQueryable<TResult>>(
|
|
76
|
+
() => normalizeQueryable(queryable as Queryable<TResult>),
|
|
77
|
+
[queryable],
|
|
78
|
+
)
|
|
69
79
|
|
|
70
|
-
|
|
80
|
+
const rcRefKey = React.useMemo(() => computeRcRefKey(store, normalized), [normalized, store])
|
|
71
81
|
|
|
72
|
-
|
|
82
|
+
const stackInfo = React.useMemo(() => captureStackInfo(), [])
|
|
73
83
|
|
|
74
|
-
|
|
75
|
-
|
|
84
|
+
const { queryRcRef, span, otelContext } = useRcResource(
|
|
85
|
+
store,
|
|
86
|
+
rcRefKey,
|
|
87
|
+
() =>
|
|
88
|
+
createQueryResource(store, normalized, stackInfo, {
|
|
89
|
+
otelSpanName: options?.otelSpanName,
|
|
90
|
+
otelContext: options?.otelContext,
|
|
91
|
+
}),
|
|
76
92
|
// We need to keep the queryRcRef alive a bit longer, so we have a second `useRcResource` below
|
|
77
93
|
// which takes care of disposing the queryRcRef
|
|
78
94
|
() => {},
|
|
79
95
|
)
|
|
80
96
|
|
|
81
|
-
|
|
82
|
-
// const queryRcRef.value.get()
|
|
83
|
-
// }
|
|
84
|
-
|
|
85
|
-
const query$ = queryRcRef.value as LiveQuery<LiveQueries.GetResult<TQuery>>
|
|
97
|
+
const query$ = queryRcRef.value
|
|
86
98
|
|
|
87
99
|
React.useDebugValue(`LiveStore:useQuery:${query$.id}:${query$.label}`)
|
|
88
|
-
// console.debug(`LiveStore:useQuery:${query$.id}:${query$.label}`)
|
|
89
|
-
|
|
90
|
-
const initialResult = React.useMemo(() => {
|
|
91
|
-
try {
|
|
92
|
-
return query$.run({
|
|
93
|
-
otelContext,
|
|
94
|
-
debugRefreshReason: {
|
|
95
|
-
_tag: 'react',
|
|
96
|
-
api: 'useQuery',
|
|
97
|
-
label: `useQuery:initial-run:${query$.label}`,
|
|
98
|
-
stackInfo,
|
|
99
|
-
},
|
|
100
|
-
})
|
|
101
|
-
} catch (cause: any) {
|
|
102
|
-
console.error('[@livestore/react:useQuery] Error running query', cause)
|
|
103
|
-
throw new Error(
|
|
104
|
-
`\
|
|
105
|
-
[@livestore/react:useQuery] Error running query: ${cause.name}
|
|
106
|
-
|
|
107
|
-
Query: ${query$.label}
|
|
108
100
|
|
|
109
|
-
React
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
Stack trace:
|
|
114
|
-
`,
|
|
115
|
-
{ cause },
|
|
116
|
-
)
|
|
117
|
-
}
|
|
118
|
-
}, [otelContext, query$, stackInfo])
|
|
101
|
+
const initialResult = React.useMemo(
|
|
102
|
+
() => runInitialQuery(query$, otelContext, stackInfo, 'react'),
|
|
103
|
+
[otelContext, query$, stackInfo],
|
|
104
|
+
)
|
|
119
105
|
|
|
120
106
|
// We know the query has a result by the time we use it; so we can synchronously populate a default state
|
|
121
|
-
const [valueRef, setValue] = useStateRefWithReactiveInput<
|
|
122
|
-
|
|
123
|
-
// TODO we probably need to change the order of `useEffect` calls, so we destroy the query at the end
|
|
124
|
-
// before calling the LS `onEffect` on it
|
|
107
|
+
const [valueRef, setValue] = useStateRefWithReactiveInput<TResult>(initialResult)
|
|
125
108
|
|
|
126
109
|
// Subscribe to future updates for this query
|
|
127
110
|
React.useEffect(() => {
|
|
128
|
-
// TODO double check whether we still need `activeSubscriptions`
|
|
129
111
|
query$.activeSubscriptions.add(stackInfo)
|
|
130
112
|
|
|
131
113
|
// Dynamic queries only set their actual label after they've been run the first time,
|
|
132
114
|
// so we're also updating the span name here.
|
|
133
115
|
span.updateName(options?.otelSpanName ?? `LiveStore:useQuery:${query$.label}`)
|
|
134
116
|
|
|
135
|
-
return store.subscribe(
|
|
136
|
-
|
|
117
|
+
return store.subscribe(
|
|
118
|
+
query$,
|
|
119
|
+
(newValue) => {
|
|
137
120
|
// NOTE: we return a reference to the result object within LiveStore;
|
|
138
121
|
// this implies that app code must not mutate the results, or else
|
|
139
122
|
// there may be weird reactivity bugs.
|
|
@@ -141,19 +124,21 @@ Stack trace:
|
|
|
141
124
|
setValue(newValue)
|
|
142
125
|
}
|
|
143
126
|
},
|
|
144
|
-
|
|
145
|
-
|
|
127
|
+
{
|
|
128
|
+
onUnsubsubscribe: () => {
|
|
129
|
+
query$.activeSubscriptions.delete(stackInfo)
|
|
130
|
+
},
|
|
131
|
+
label: query$.label,
|
|
132
|
+
otelContext,
|
|
146
133
|
},
|
|
147
|
-
|
|
148
|
-
otelContext,
|
|
149
|
-
})
|
|
134
|
+
)
|
|
150
135
|
}, [stackInfo, query$, setValue, store, valueRef, otelContext, span, options?.otelSpanName])
|
|
151
136
|
|
|
152
137
|
useRcResource(
|
|
138
|
+
store,
|
|
153
139
|
rcRefKey,
|
|
154
140
|
() => ({ queryRcRef, span }),
|
|
155
141
|
({ queryRcRef, span }) => {
|
|
156
|
-
// console.debug('deref', queryRcRef.value.id, queryRcRef.value.label)
|
|
157
142
|
queryRcRef.deref()
|
|
158
143
|
span.end()
|
|
159
144
|
},
|
|
@@ -2,20 +2,23 @@ import * as ReactTesting from '@testing-library/react'
|
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
4
|
|
|
5
|
-
import { __resetUseRcResourceCache, useRcResource } from './useRcResource.
|
|
5
|
+
import { __resetUseRcResourceCache, useRcResource } from './useRcResource.ts'
|
|
6
6
|
|
|
7
7
|
describe.each([{ strictMode: true }, { strictMode: false }])('useRcResource (strictMode=%s)', ({ strictMode }) => {
|
|
8
8
|
beforeEach(() => {
|
|
9
9
|
__resetUseRcResourceCache()
|
|
10
10
|
})
|
|
11
11
|
|
|
12
|
-
const wrapper = strictMode ? React.StrictMode : React.Fragment
|
|
12
|
+
const wrapper = strictMode === true ? React.StrictMode : React.Fragment
|
|
13
13
|
|
|
14
14
|
it('should create a stateful entity using make and call cleanup on unmount', () => {
|
|
15
|
+
const scope = {}
|
|
15
16
|
const makeSpy = vi.fn(() => Symbol('statefulResource'))
|
|
16
17
|
const cleanupSpy = vi.fn()
|
|
17
18
|
|
|
18
|
-
const { result, unmount } = ReactTesting.renderHook(() => useRcResource('key-1', makeSpy, cleanupSpy), {
|
|
19
|
+
const { result, unmount } = ReactTesting.renderHook(() => useRcResource(scope, 'key-1', makeSpy, cleanupSpy), {
|
|
20
|
+
wrapper,
|
|
21
|
+
})
|
|
19
22
|
|
|
20
23
|
expect(makeSpy).toHaveBeenCalledTimes(1)
|
|
21
24
|
expect(result.current).toBeDefined()
|
|
@@ -26,11 +29,12 @@ describe.each([{ strictMode: true }, { strictMode: false }])('useRcResource (str
|
|
|
26
29
|
})
|
|
27
30
|
|
|
28
31
|
it('should reuse the same entity when the key remains unchanged', () => {
|
|
32
|
+
const scope = {}
|
|
29
33
|
const makeSpy = vi.fn(() => Symbol('statefulResource'))
|
|
30
34
|
const cleanupSpy = vi.fn()
|
|
31
35
|
|
|
32
36
|
const { result, rerender, unmount } = ReactTesting.renderHook(
|
|
33
|
-
({ key }) => useRcResource(key, makeSpy, cleanupSpy),
|
|
37
|
+
({ key }) => useRcResource(scope, key, makeSpy, cleanupSpy),
|
|
34
38
|
{ initialProps: { key: 'consistent-key' }, wrapper },
|
|
35
39
|
)
|
|
36
40
|
|
|
@@ -48,11 +52,12 @@ describe.each([{ strictMode: true }, { strictMode: false }])('useRcResource (str
|
|
|
48
52
|
})
|
|
49
53
|
|
|
50
54
|
it('should dispose the previous instance when the key changes', () => {
|
|
55
|
+
const scope = {}
|
|
51
56
|
const makeSpy = vi.fn(() => Symbol('statefulResource'))
|
|
52
57
|
const cleanupSpy = vi.fn()
|
|
53
58
|
|
|
54
59
|
const { result, rerender, unmount } = ReactTesting.renderHook(
|
|
55
|
-
({ key }) => useRcResource(key, makeSpy, cleanupSpy),
|
|
60
|
+
({ key }) => useRcResource(scope, key, makeSpy, cleanupSpy),
|
|
56
61
|
{ initialProps: { key: 'a' }, wrapper },
|
|
57
62
|
)
|
|
58
63
|
|
|
@@ -71,18 +76,18 @@ describe.each([{ strictMode: true }, { strictMode: false }])('useRcResource (str
|
|
|
71
76
|
})
|
|
72
77
|
|
|
73
78
|
it('should not dispose the entity until all consumers unmount', () => {
|
|
79
|
+
const scope = {}
|
|
74
80
|
const makeSpy = vi.fn(() => Symbol('statefulResource'))
|
|
75
81
|
const cleanupSpy = vi.fn()
|
|
76
82
|
|
|
77
|
-
// Simulate two consumers using the same key independently.
|
|
78
|
-
const { unmount: unmount1 } = ReactTesting.renderHook(
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
// Simulate two consumers using the same (scope, key) pair independently.
|
|
84
|
+
const { unmount: unmount1 } = ReactTesting.renderHook(
|
|
85
|
+
() => useRcResource(scope, 'shared-key', makeSpy, cleanupSpy),
|
|
86
|
+
{ wrapper },
|
|
87
|
+
)
|
|
81
88
|
const { unmount: unmount2, result } = ReactTesting.renderHook(
|
|
82
|
-
() => useRcResource('shared-key', makeSpy, cleanupSpy),
|
|
83
|
-
{
|
|
84
|
-
wrapper,
|
|
85
|
-
},
|
|
89
|
+
() => useRcResource(scope, 'shared-key', makeSpy, cleanupSpy),
|
|
90
|
+
{ wrapper },
|
|
86
91
|
)
|
|
87
92
|
|
|
88
93
|
expect(result.current).toBeDefined()
|
|
@@ -98,10 +103,11 @@ describe.each([{ strictMode: true }, { strictMode: false }])('useRcResource (str
|
|
|
98
103
|
})
|
|
99
104
|
|
|
100
105
|
it('should handle rapid key changes correctly', () => {
|
|
106
|
+
const scope = {}
|
|
101
107
|
const makeSpy = vi.fn(() => Symbol('statefulResource'))
|
|
102
108
|
const cleanupSpy = vi.fn()
|
|
103
109
|
|
|
104
|
-
const { rerender, unmount } = ReactTesting.renderHook(({ key }) => useRcResource(key, makeSpy, cleanupSpy), {
|
|
110
|
+
const { rerender, unmount } = ReactTesting.renderHook(({ key }) => useRcResource(scope, key, makeSpy, cleanupSpy), {
|
|
105
111
|
initialProps: { key: '1' },
|
|
106
112
|
wrapper,
|
|
107
113
|
})
|
|
@@ -119,49 +125,99 @@ describe.each([{ strictMode: true }, { strictMode: false }])('useRcResource (str
|
|
|
119
125
|
// Unmounting the final consumer disposes the key '3' instance.
|
|
120
126
|
expect(cleanupSpy).toHaveBeenCalledTimes(3)
|
|
121
127
|
})
|
|
122
|
-
})
|
|
123
128
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
129
|
+
it('should isolate entities created with the same key but different scopes', () => {
|
|
130
|
+
const scopeA = { tag: 'A' }
|
|
131
|
+
const scopeB = { tag: 'B' }
|
|
132
|
+
const makeSpy = vi.fn(() => Symbol('statefulResource'))
|
|
133
|
+
const cleanupSpy = vi.fn()
|
|
134
|
+
|
|
135
|
+
const { result: resultA } = ReactTesting.renderHook(
|
|
136
|
+
() => useRcResource(scopeA, 'shared-key', makeSpy, cleanupSpy),
|
|
137
|
+
{ wrapper },
|
|
138
|
+
)
|
|
139
|
+
const { result: resultB } = ReactTesting.renderHook(
|
|
140
|
+
() => useRcResource(scopeB, 'shared-key', makeSpy, cleanupSpy),
|
|
141
|
+
{ wrapper },
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
expect(resultA.current).not.toBe(resultB.current)
|
|
145
|
+
expect(makeSpy).toHaveBeenCalledTimes(2)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('should dispose the previous entity when the scope changes (key unchanged)', () => {
|
|
149
|
+
const scopeA = { tag: 'A' }
|
|
150
|
+
const scopeB = { tag: 'B' }
|
|
151
|
+
const makeSpy = vi.fn(() => Symbol('statefulResource'))
|
|
152
|
+
const cleanupSpy = vi.fn()
|
|
153
|
+
|
|
154
|
+
const { result, rerender, unmount } = ReactTesting.renderHook(
|
|
155
|
+
({ scope }) => useRcResource(scope, 'k', makeSpy, cleanupSpy),
|
|
156
|
+
{ initialProps: { scope: scopeA }, wrapper },
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
const instanceA = result.current
|
|
160
|
+
expect(makeSpy).toHaveBeenCalledTimes(1)
|
|
161
|
+
|
|
162
|
+
rerender({ scope: scopeB })
|
|
163
|
+
const instanceB = result.current
|
|
164
|
+
|
|
165
|
+
expect(instanceA).not.toBe(instanceB)
|
|
166
|
+
expect(makeSpy).toHaveBeenCalledTimes(2)
|
|
167
|
+
// The scopeA entry's last consumer left when we switched scopes → cleaned up.
|
|
168
|
+
expect(cleanupSpy).toHaveBeenCalledTimes(1)
|
|
169
|
+
|
|
170
|
+
unmount()
|
|
171
|
+
expect(cleanupSpy).toHaveBeenCalledTimes(2)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('should not reuse a cached entity after the scope is replaced', () => {
|
|
175
|
+
const makeSpy = vi.fn(() => Symbol('statefulResource'))
|
|
176
|
+
const cleanupSpy = vi.fn()
|
|
177
|
+
|
|
178
|
+
const scope1 = {}
|
|
179
|
+
const { result: result1, unmount: unmount1 } = ReactTesting.renderHook(
|
|
180
|
+
() => useRcResource(scope1, 'k', makeSpy, cleanupSpy),
|
|
181
|
+
{ wrapper },
|
|
182
|
+
)
|
|
183
|
+
const instance1 = result1.current
|
|
184
|
+
unmount1()
|
|
185
|
+
expect(cleanupSpy).toHaveBeenCalledTimes(1)
|
|
186
|
+
|
|
187
|
+
// Fresh scope, same string key — must NOT reuse the (already-disposed) entry.
|
|
188
|
+
const scope2 = {}
|
|
189
|
+
const { result: result2, unmount: unmount2 } = ReactTesting.renderHook(
|
|
190
|
+
() => useRcResource(scope2, 'k', makeSpy, cleanupSpy),
|
|
191
|
+
{ wrapper },
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
expect(result2.current).not.toBe(instance1)
|
|
195
|
+
expect(makeSpy).toHaveBeenCalledTimes(2)
|
|
196
|
+
|
|
197
|
+
unmount2()
|
|
198
|
+
expect(cleanupSpy).toHaveBeenCalledTimes(2)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('should share the entity across components within the same scope', () => {
|
|
202
|
+
const scope = {}
|
|
203
|
+
const makeSpy = vi.fn(() => Symbol('statefulResource'))
|
|
204
|
+
const cleanupSpy = vi.fn()
|
|
205
|
+
|
|
206
|
+
const { result: r1, unmount: unmount1 } = ReactTesting.renderHook(
|
|
207
|
+
() => useRcResource(scope, 'k', makeSpy, cleanupSpy),
|
|
208
|
+
{ wrapper },
|
|
209
|
+
)
|
|
210
|
+
const { result: r2, unmount: unmount2 } = ReactTesting.renderHook(
|
|
211
|
+
() => useRcResource(scope, 'k', makeSpy, cleanupSpy),
|
|
212
|
+
{ wrapper },
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
expect(r1.current).toBe(r2.current)
|
|
216
|
+
expect(makeSpy).toHaveBeenCalledTimes(1)
|
|
217
|
+
|
|
218
|
+
unmount1()
|
|
219
|
+
expect(cleanupSpy).not.toHaveBeenCalled()
|
|
220
|
+
unmount2()
|
|
221
|
+
expect(cleanupSpy).toHaveBeenCalledTimes(1)
|
|
222
|
+
})
|
|
223
|
+
})
|