@livestore/react 0.4.0-dev.2 → 0.4.0-dev.20
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/dist/.tsbuildinfo +1 -1
- package/dist/LiveStoreProvider.d.ts +6 -7
- package/dist/LiveStoreProvider.d.ts.map +1 -1
- package/dist/LiveStoreProvider.js +39 -24
- package/dist/LiveStoreProvider.js.map +1 -1
- package/dist/LiveStoreProvider.test.js +7 -7
- package/dist/LiveStoreProvider.test.js.map +1 -1
- package/dist/__tests__/fixture.d.ts +34 -12
- package/dist/__tests__/fixture.d.ts.map +1 -1
- package/dist/__tests__/fixture.js +13 -5
- package/dist/__tests__/fixture.js.map +1 -1
- package/dist/experimental/components/LiveList.js +1 -1
- package/dist/experimental/mod.d.ts +1 -0
- package/dist/experimental/mod.d.ts.map +1 -1
- package/dist/experimental/mod.js +1 -0
- package/dist/experimental/mod.js.map +1 -1
- package/dist/experimental/multi-store/StoreRegistry.d.ts +61 -0
- package/dist/experimental/multi-store/StoreRegistry.d.ts.map +1 -0
- package/dist/experimental/multi-store/StoreRegistry.js +275 -0
- package/dist/experimental/multi-store/StoreRegistry.js.map +1 -0
- package/dist/experimental/multi-store/StoreRegistry.test.d.ts +2 -0
- package/dist/experimental/multi-store/StoreRegistry.test.d.ts.map +1 -0
- package/dist/experimental/multi-store/StoreRegistry.test.js +464 -0
- package/dist/experimental/multi-store/StoreRegistry.test.js.map +1 -0
- package/dist/experimental/multi-store/StoreRegistryContext.d.ts +10 -0
- package/dist/experimental/multi-store/StoreRegistryContext.d.ts.map +1 -0
- package/dist/experimental/multi-store/StoreRegistryContext.js +15 -0
- package/dist/experimental/multi-store/StoreRegistryContext.js.map +1 -0
- package/dist/experimental/multi-store/mod.d.ts +6 -0
- package/dist/experimental/multi-store/mod.d.ts.map +1 -0
- package/dist/experimental/multi-store/mod.js +6 -0
- package/dist/experimental/multi-store/mod.js.map +1 -0
- package/dist/experimental/multi-store/storeOptions.d.ts +4 -0
- package/dist/experimental/multi-store/storeOptions.d.ts.map +1 -0
- package/dist/experimental/multi-store/storeOptions.js +4 -0
- package/dist/experimental/multi-store/storeOptions.js.map +1 -0
- package/dist/experimental/multi-store/types.d.ts +44 -0
- package/dist/experimental/multi-store/types.d.ts.map +1 -0
- package/dist/experimental/multi-store/types.js +2 -0
- package/dist/experimental/multi-store/types.js.map +1 -0
- package/dist/experimental/multi-store/useStore.d.ts +11 -0
- package/dist/experimental/multi-store/useStore.d.ts.map +1 -0
- package/dist/experimental/multi-store/useStore.js +21 -0
- package/dist/experimental/multi-store/useStore.js.map +1 -0
- package/dist/experimental/multi-store/useStore.test.d.ts +2 -0
- package/dist/experimental/multi-store/useStore.test.d.ts.map +1 -0
- package/dist/experimental/multi-store/useStore.test.js +144 -0
- package/dist/experimental/multi-store/useStore.test.js.map +1 -0
- package/dist/mod.d.ts +1 -1
- package/dist/mod.d.ts.map +1 -1
- package/dist/mod.js.map +1 -1
- package/dist/useClientDocument.d.ts +10 -13
- package/dist/useClientDocument.d.ts.map +1 -1
- package/dist/useClientDocument.js +4 -5
- package/dist/useClientDocument.js.map +1 -1
- package/dist/useClientDocument.test.js +29 -7
- package/dist/useClientDocument.test.js.map +1 -1
- package/dist/useQuery.d.ts +28 -6
- package/dist/useQuery.d.ts.map +1 -1
- package/dist/useQuery.js +63 -18
- package/dist/useQuery.js.map +1 -1
- package/dist/useQuery.test.js +35 -11
- package/dist/useQuery.test.js.map +1 -1
- package/dist/useRcResource.test.js +1 -1
- package/dist/useStore.d.ts +2 -1
- package/dist/useStore.d.ts.map +1 -1
- package/dist/useStore.js +1 -1
- package/dist/useStore.js.map +1 -1
- package/package.json +14 -14
- package/src/LiveStoreProvider.test.tsx +7 -7
- package/src/LiveStoreProvider.tsx +58 -45
- package/src/__snapshots__/useClientDocument.test.tsx.snap +208 -100
- package/src/__snapshots__/useQuery.test.tsx.snap +400 -128
- package/src/__tests__/fixture.tsx +23 -24
- package/src/experimental/components/LiveList.tsx +1 -1
- package/src/experimental/mod.ts +1 -0
- package/src/experimental/multi-store/StoreRegistry.test.ts +631 -0
- package/src/experimental/multi-store/StoreRegistry.ts +347 -0
- package/src/experimental/multi-store/StoreRegistryContext.tsx +23 -0
- package/src/experimental/multi-store/mod.ts +5 -0
- package/src/experimental/multi-store/storeOptions.ts +8 -0
- package/src/experimental/multi-store/types.ts +55 -0
- package/src/experimental/multi-store/useStore.test.tsx +197 -0
- package/src/experimental/multi-store/useStore.ts +34 -0
- package/src/mod.ts +2 -1
- package/src/useClientDocument.test.tsx +105 -75
- package/src/useClientDocument.ts +23 -13
- package/src/useQuery.test.tsx +62 -11
- package/src/useQuery.ts +98 -27
- package/src/useRcResource.test.tsx +1 -1
- package/src/useStore.ts +4 -3
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/** biome-ignore-all lint/a11y/useValidAriaRole: not needed for testing */
|
|
2
2
|
/** biome-ignore-all lint/a11y/noStaticElementInteractions: not needed for testing */
|
|
3
3
|
import * as LiveStore from '@livestore/livestore'
|
|
4
|
-
import {
|
|
4
|
+
import { StoreInternalsSymbol } from '@livestore/livestore'
|
|
5
|
+
import { getAllSimplifiedRootSpans, getSimplifiedRootSpan } from '@livestore/livestore/internal/testing-utils'
|
|
5
6
|
import { Effect, ReadonlyRecord, Schema } from '@livestore/utils/effect'
|
|
6
7
|
import { Vitest } from '@livestore/utils-dev/node-vitest'
|
|
7
8
|
import * as otel from '@opentelemetry/api'
|
|
@@ -10,9 +11,9 @@ import * as ReactTesting from '@testing-library/react'
|
|
|
10
11
|
import type React from 'react'
|
|
11
12
|
import { beforeEach, expect, it } from 'vitest'
|
|
12
13
|
|
|
13
|
-
import { events, makeTodoMvcReact, tables } from './__tests__/fixture.
|
|
14
|
-
import type * as LiveStoreReact from './mod.
|
|
15
|
-
import { __resetUseRcResourceCache } from './useRcResource.
|
|
14
|
+
import { events, makeTodoMvcReact, tables } from './__tests__/fixture.tsx'
|
|
15
|
+
import type * as LiveStoreReact from './mod.ts'
|
|
16
|
+
import { __resetUseRcResourceCache } from './useRcResource.ts'
|
|
16
17
|
|
|
17
18
|
// const strictMode = process.env.REACT_STRICT_MODE !== undefined
|
|
18
19
|
|
|
@@ -39,12 +40,12 @@ Vitest.describe('useClientDocument', () => {
|
|
|
39
40
|
expect(result.current.id).toBe('u1')
|
|
40
41
|
expect(result.current.state.username).toBe('')
|
|
41
42
|
expect(renderCount.val).toBe(1)
|
|
42
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
43
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
43
44
|
store.commit(tables.userInfo.set({ username: 'username_u2' }, 'u2'))
|
|
44
45
|
|
|
45
46
|
rerender('u2')
|
|
46
47
|
|
|
47
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
48
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
48
49
|
expect(result.current.id).toBe('u2')
|
|
49
50
|
expect(result.current.state.username).toBe('username_u2')
|
|
50
51
|
expect(renderCount.val).toBe(2)
|
|
@@ -224,81 +225,110 @@ Vitest.describe('useClientDocument', () => {
|
|
|
224
225
|
}),
|
|
225
226
|
)
|
|
226
227
|
|
|
227
|
-
Vitest.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
async ({ strictMode }) => {
|
|
231
|
-
const exporter = new InMemorySpanExporter()
|
|
228
|
+
Vitest.scopedLive('kv client document overwrites value (Schema.Any, no partial merge)', () =>
|
|
229
|
+
Effect.gen(function* () {
|
|
230
|
+
const { wrapper, store, renderCount } = yield* makeTodoMvcReact({})
|
|
232
231
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
232
|
+
const { result } = ReactTesting.renderHook(
|
|
233
|
+
(id: string) => {
|
|
234
|
+
renderCount.inc()
|
|
235
|
+
|
|
236
|
+
const [state, setState] = store.useClientDocument(tables.kv, id)
|
|
237
|
+
return { state, setState, id }
|
|
238
|
+
},
|
|
239
|
+
{ wrapper, initialProps: 'k1' },
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
expect(result.current.id).toBe('k1')
|
|
243
|
+
expect(result.current.state).toBe(null)
|
|
244
|
+
expect(renderCount.val).toBe(1)
|
|
236
245
|
|
|
237
|
-
|
|
246
|
+
ReactTesting.act(() => result.current.setState(1))
|
|
247
|
+
expect(result.current.state).toEqual(1)
|
|
248
|
+
expect(renderCount.val).toBe(2)
|
|
249
|
+
|
|
250
|
+
ReactTesting.act(() => result.current.setState({ b: 2 }))
|
|
251
|
+
expect(result.current.state).toEqual({ b: 2 })
|
|
252
|
+
expect(renderCount.val).toBe(3)
|
|
253
|
+
}),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
Vitest.describe('otel', () => {
|
|
257
|
+
it.each([
|
|
258
|
+
{ strictMode: true },
|
|
259
|
+
{ strictMode: false },
|
|
260
|
+
])('should update the data based on component key strictMode=%s', async ({ strictMode }) => {
|
|
261
|
+
const exporter = new InMemorySpanExporter()
|
|
262
|
+
|
|
263
|
+
const provider = new BasicTracerProvider({
|
|
264
|
+
spanProcessors: [new SimpleSpanProcessor(exporter)],
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
const otelTracer = provider.getTracer(`testing-${strictMode ? 'strict' : 'non-strict'}`)
|
|
268
|
+
|
|
269
|
+
const span = otelTracer.startSpan('test-root')
|
|
270
|
+
const otelContext = otel.trace.setSpan(otel.context.active(), span)
|
|
271
|
+
|
|
272
|
+
await Effect.gen(function* () {
|
|
273
|
+
const { wrapper, store, renderCount } = yield* makeTodoMvcReact({
|
|
274
|
+
otelContext,
|
|
275
|
+
otelTracer,
|
|
276
|
+
strictMode,
|
|
277
|
+
})
|
|
238
278
|
|
|
239
|
-
const
|
|
240
|
-
|
|
279
|
+
const { result, rerender, unmount } = ReactTesting.renderHook(
|
|
280
|
+
(userId: string) => {
|
|
281
|
+
renderCount.inc()
|
|
241
282
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
})
|
|
283
|
+
const [state, setState, id] = store.useClientDocument(tables.userInfo, userId)
|
|
284
|
+
return { state, setState, id }
|
|
285
|
+
},
|
|
286
|
+
{ wrapper, initialProps: 'u1' },
|
|
287
|
+
)
|
|
248
288
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
289
|
+
expect(result.current.id).toBe('u1')
|
|
290
|
+
expect(result.current.state.username).toBe('')
|
|
291
|
+
expect(renderCount.val).toBe(1)
|
|
292
|
+
|
|
293
|
+
// For u2 we'll make sure that the row already exists,
|
|
294
|
+
// so the lazy `insert` will be skipped
|
|
295
|
+
ReactTesting.act(() => store.commit(events.UserInfoSet({ username: 'username_u2' }, 'u2')))
|
|
296
|
+
|
|
297
|
+
rerender('u2')
|
|
298
|
+
|
|
299
|
+
expect(result.current.id).toBe('u2')
|
|
300
|
+
expect(result.current.state.username).toBe('username_u2')
|
|
301
|
+
expect(renderCount.val).toBe(2)
|
|
302
|
+
|
|
303
|
+
unmount()
|
|
304
|
+
span.end()
|
|
305
|
+
}).pipe(Effect.scoped, Effect.tapCauseLogPretty, Effect.runPromise)
|
|
306
|
+
|
|
307
|
+
await provider.forceFlush()
|
|
308
|
+
|
|
309
|
+
const mapAttributes = (attributes: otel.Attributes) => {
|
|
310
|
+
return ReadonlyRecord.map(attributes, (val, key) => {
|
|
311
|
+
if (key === 'code.stacktrace') {
|
|
312
|
+
return '<STACKTRACE>'
|
|
313
|
+
} else if (key === 'firstStackInfo') {
|
|
314
|
+
const stackInfo = JSON.parse(val as string) as LiveStore.StackInfo
|
|
315
|
+
// stackInfo.frames.shift() // Removes `renderHook.wrapper` from the stack
|
|
316
|
+
stackInfo.frames.forEach((_) => {
|
|
317
|
+
if (_.name.includes('renderHook.wrapper')) {
|
|
318
|
+
_.name = 'renderHook.wrapper'
|
|
319
|
+
}
|
|
320
|
+
_.filePath = '__REPLACED_FOR_SNAPSHOT__'
|
|
321
|
+
})
|
|
322
|
+
return JSON.stringify(stackInfo)
|
|
323
|
+
}
|
|
324
|
+
return val
|
|
325
|
+
})
|
|
326
|
+
}
|
|
252
327
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
},
|
|
256
|
-
{ wrapper, initialProps: 'u1' },
|
|
257
|
-
)
|
|
328
|
+
expect(getSimplifiedRootSpan(exporter, 'createStore', mapAttributes)).toMatchSnapshot()
|
|
329
|
+
expect(getAllSimplifiedRootSpans(exporter, 'LiveStore:commit', mapAttributes)).toMatchSnapshot()
|
|
258
330
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
expect(renderCount.val).toBe(1)
|
|
262
|
-
|
|
263
|
-
// For u2 we'll make sure that the row already exists,
|
|
264
|
-
// so the lazy `insert` will be skipped
|
|
265
|
-
ReactTesting.act(() => store.commit(events.UserInfoSet({ username: 'username_u2' }, 'u2')))
|
|
266
|
-
|
|
267
|
-
rerender('u2')
|
|
268
|
-
|
|
269
|
-
expect(result.current.id).toBe('u2')
|
|
270
|
-
expect(result.current.state.username).toBe('username_u2')
|
|
271
|
-
expect(renderCount.val).toBe(2)
|
|
272
|
-
|
|
273
|
-
unmount()
|
|
274
|
-
span.end()
|
|
275
|
-
}).pipe(Effect.scoped, Effect.tapCauseLogPretty, Effect.runPromise)
|
|
276
|
-
|
|
277
|
-
await provider.forceFlush()
|
|
278
|
-
|
|
279
|
-
const mapAttributes = (attributes: otel.Attributes) => {
|
|
280
|
-
return ReadonlyRecord.map(attributes, (val, key) => {
|
|
281
|
-
if (key === 'code.stacktrace') {
|
|
282
|
-
return '<STACKTRACE>'
|
|
283
|
-
} else if (key === 'firstStackInfo') {
|
|
284
|
-
const stackInfo = JSON.parse(val as string) as LiveStore.StackInfo
|
|
285
|
-
// stackInfo.frames.shift() // Removes `renderHook.wrapper` from the stack
|
|
286
|
-
stackInfo.frames.forEach((_) => {
|
|
287
|
-
if (_.name.includes('renderHook.wrapper')) {
|
|
288
|
-
_.name = 'renderHook.wrapper'
|
|
289
|
-
}
|
|
290
|
-
_.filePath = '__REPLACED_FOR_SNAPSHOT__'
|
|
291
|
-
})
|
|
292
|
-
return JSON.stringify(stackInfo)
|
|
293
|
-
}
|
|
294
|
-
return val
|
|
295
|
-
})
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
expect(getSimplifiedRootSpan(exporter, mapAttributes)).toMatchSnapshot()
|
|
299
|
-
|
|
300
|
-
await provider.shutdown()
|
|
301
|
-
},
|
|
302
|
-
)
|
|
331
|
+
await provider.shutdown()
|
|
332
|
+
})
|
|
303
333
|
})
|
|
304
334
|
})
|
package/src/useClientDocument.ts
CHANGED
|
@@ -3,13 +3,13 @@ import { SessionIdSymbol } from '@livestore/common'
|
|
|
3
3
|
import { State } from '@livestore/common/schema'
|
|
4
4
|
import type { LiveQuery, LiveQueryDef, Store } from '@livestore/livestore'
|
|
5
5
|
import { queryDb } from '@livestore/livestore'
|
|
6
|
-
import { shouldNeverHappen } from '@livestore/utils'
|
|
6
|
+
import { omitUndefineds, shouldNeverHappen } from '@livestore/utils'
|
|
7
7
|
import React from 'react'
|
|
8
8
|
|
|
9
9
|
import { LiveStoreContext } from './LiveStoreContext.ts'
|
|
10
10
|
import { useQueryRef } from './useQuery.ts'
|
|
11
11
|
|
|
12
|
-
export type
|
|
12
|
+
export type UseClientDocumentResult<TTableDef extends State.SQLite.ClientDocumentTableDef.TraitAny> = [
|
|
13
13
|
row: TTableDef['Value'],
|
|
14
14
|
setRow: StateSetters<TTableDef>,
|
|
15
15
|
id: string,
|
|
@@ -54,13 +54,17 @@ export const useClientDocument: {
|
|
|
54
54
|
any,
|
|
55
55
|
any,
|
|
56
56
|
any,
|
|
57
|
-
{
|
|
57
|
+
{
|
|
58
|
+
partialSet: boolean
|
|
59
|
+
/** Default value to use instead of the default value from the table definition */
|
|
60
|
+
default: any
|
|
61
|
+
}
|
|
58
62
|
>,
|
|
59
63
|
>(
|
|
60
64
|
table: TTableDef,
|
|
61
65
|
id?: State.SQLite.ClientDocumentTableDef.DefaultIdType<TTableDef> | SessionIdSymbol,
|
|
62
66
|
options?: Partial<RowQuery.GetOrCreateOptions<TTableDef>>,
|
|
63
|
-
):
|
|
67
|
+
): UseClientDocumentResult<TTableDef>
|
|
64
68
|
|
|
65
69
|
// case: no default id → id arg is required
|
|
66
70
|
<
|
|
@@ -68,20 +72,24 @@ export const useClientDocument: {
|
|
|
68
72
|
any,
|
|
69
73
|
any,
|
|
70
74
|
any,
|
|
71
|
-
{
|
|
75
|
+
{
|
|
76
|
+
partialSet: boolean
|
|
77
|
+
/** Default value to use instead of the default value from the table definition */
|
|
78
|
+
default: any
|
|
79
|
+
}
|
|
72
80
|
>,
|
|
73
81
|
>(
|
|
74
82
|
table: TTableDef,
|
|
75
83
|
// TODO adjust so it works with arbitrary primary keys or unique constraints
|
|
76
84
|
id: State.SQLite.ClientDocumentTableDef.DefaultIdType<TTableDef> | string | SessionIdSymbol,
|
|
77
85
|
options?: Partial<RowQuery.GetOrCreateOptions<TTableDef>>,
|
|
78
|
-
):
|
|
86
|
+
): UseClientDocumentResult<TTableDef>
|
|
79
87
|
} = <TTableDef extends State.SQLite.ClientDocumentTableDef.Any>(
|
|
80
88
|
table: TTableDef,
|
|
81
89
|
idOrOptions?: string | SessionIdSymbol,
|
|
82
90
|
options_?: Partial<RowQuery.GetOrCreateOptions<TTableDef>>,
|
|
83
91
|
storeArg?: { store?: Store },
|
|
84
|
-
):
|
|
92
|
+
): UseClientDocumentResult<TTableDef> => {
|
|
85
93
|
const id =
|
|
86
94
|
typeof idOrOptions === 'string' || idOrOptions === SessionIdSymbol
|
|
87
95
|
? idOrOptions
|
|
@@ -97,14 +105,13 @@ export const useClientDocument: {
|
|
|
97
105
|
const tableName = table.sqliteDef.name
|
|
98
106
|
|
|
99
107
|
const store =
|
|
100
|
-
storeArg?.store ??
|
|
101
|
-
// biome-ignore lint/correctness/useHookAtTopLevel: store is stable
|
|
108
|
+
storeArg?.store ?? // biome-ignore lint/correctness/useHookAtTopLevel: store is stable
|
|
102
109
|
React.useContext(LiveStoreContext)?.store ??
|
|
103
110
|
shouldNeverHappen(`No store provided to useClientDocument`)
|
|
104
111
|
|
|
105
112
|
// console.debug('useClientDocument', tableName, id)
|
|
106
113
|
|
|
107
|
-
const idStr: string = id === SessionIdSymbol ? store.
|
|
114
|
+
const idStr: string = id === SessionIdSymbol ? store.sessionId : id
|
|
108
115
|
|
|
109
116
|
type QueryDef = LiveQueryDef<TTableDef['Value']>
|
|
110
117
|
const queryDef: QueryDef = React.useMemo(
|
|
@@ -117,7 +124,7 @@ export const useClientDocument: {
|
|
|
117
124
|
|
|
118
125
|
const queryRef = useQueryRef(queryDef, {
|
|
119
126
|
otelSpanName: `LiveStore:useClientDocument:${tableName}:${idStr}`,
|
|
120
|
-
store: storeArg?.store,
|
|
127
|
+
...omitUndefineds({ store: storeArg?.store }),
|
|
121
128
|
})
|
|
122
129
|
|
|
123
130
|
const setState = React.useMemo<StateSetters<TTableDef>>(
|
|
@@ -134,10 +141,13 @@ export const useClientDocument: {
|
|
|
134
141
|
}
|
|
135
142
|
|
|
136
143
|
export type Dispatch<A> = (action: A) => void
|
|
137
|
-
export type
|
|
144
|
+
export type SetStateActionPartial<S> = Partial<S> | ((previousValue: S) => Partial<S>)
|
|
145
|
+
export type SetStateAction<S> = S | ((previousValue: S) => S)
|
|
138
146
|
|
|
139
147
|
export type StateSetters<TTableDef extends State.SQLite.ClientDocumentTableDef.TraitAny> = Dispatch<
|
|
140
|
-
|
|
148
|
+
TTableDef[State.SQLite.ClientDocumentTableDefSymbol]['options']['partialSet'] extends false
|
|
149
|
+
? SetStateAction<TTableDef['Value']>
|
|
150
|
+
: SetStateActionPartial<TTableDef['Value']>
|
|
141
151
|
>
|
|
142
152
|
|
|
143
153
|
const validateTableOptions = (table: State.SQLite.TableDef<any, any>) => {
|
package/src/useQuery.test.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** biome-ignore-all lint/a11y: test */
|
|
2
2
|
import * as LiveStore from '@livestore/livestore'
|
|
3
|
-
import { queryDb, signal } from '@livestore/livestore'
|
|
3
|
+
import { queryDb, StoreInternalsSymbol, signal } from '@livestore/livestore'
|
|
4
4
|
import { RG } from '@livestore/livestore/internal/testing-utils'
|
|
5
5
|
import { Effect, Schema } from '@livestore/utils/effect'
|
|
6
6
|
import { Vitest } from '@livestore/utils-dev/node-vitest'
|
|
@@ -10,8 +10,8 @@ import React from 'react'
|
|
|
10
10
|
import * as ReactWindow from 'react-window'
|
|
11
11
|
import { expect } from 'vitest'
|
|
12
12
|
|
|
13
|
-
import { events, makeTodoMvcReact, tables } from './__tests__/fixture.
|
|
14
|
-
import { __resetUseRcResourceCache } from './useRcResource.
|
|
13
|
+
import { events, makeTodoMvcReact, tables } from './__tests__/fixture.tsx'
|
|
14
|
+
import { __resetUseRcResourceCache } from './useRcResource.ts'
|
|
15
15
|
|
|
16
16
|
Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
|
|
17
17
|
'useQuery (strictMode=%s)',
|
|
@@ -38,14 +38,14 @@ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
|
|
|
38
38
|
|
|
39
39
|
expect(result.current.length).toBe(0)
|
|
40
40
|
expect(renderCount.val).toBe(1)
|
|
41
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
41
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
42
42
|
|
|
43
43
|
ReactTesting.act(() => store.commit(events.todoCreated({ id: 't1', text: 'buy milk', completed: false })))
|
|
44
44
|
|
|
45
45
|
expect(result.current.length).toBe(1)
|
|
46
46
|
expect(result.current[0]!.text).toBe('buy milk')
|
|
47
47
|
expect(renderCount.val).toBe(2)
|
|
48
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
48
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
49
49
|
}),
|
|
50
50
|
)
|
|
51
51
|
|
|
@@ -80,19 +80,25 @@ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
|
|
|
80
80
|
|
|
81
81
|
expect(result.current).toBe('buy milk')
|
|
82
82
|
expect(renderCount.val).toBe(1)
|
|
83
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
|
|
83
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
|
|
84
|
+
'1: after first render',
|
|
85
|
+
)
|
|
84
86
|
|
|
85
87
|
ReactTesting.act(() => store.commit(events.todoUpdated({ id: 't1', text: 'buy soy milk' })))
|
|
86
88
|
|
|
87
89
|
expect(result.current).toBe('buy soy milk')
|
|
88
90
|
expect(renderCount.val).toBe(2)
|
|
89
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
|
|
91
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
|
|
92
|
+
'2: after first commit',
|
|
93
|
+
)
|
|
90
94
|
|
|
91
95
|
rerender('t2')
|
|
92
96
|
|
|
93
97
|
expect(result.current).toBe('buy eggs')
|
|
94
98
|
expect(renderCount.val).toBe(3)
|
|
95
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
|
|
99
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
|
|
100
|
+
'3: after forced rerender',
|
|
101
|
+
)
|
|
96
102
|
}),
|
|
97
103
|
)
|
|
98
104
|
|
|
@@ -120,19 +126,19 @@ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
|
|
|
120
126
|
|
|
121
127
|
expect(result.current).toBe('buy milk')
|
|
122
128
|
expect(renderCount.val).toBe(1)
|
|
123
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
129
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
124
130
|
|
|
125
131
|
ReactTesting.act(() => store.commit(events.todoUpdated({ id: 't1', text: 'buy soy milk' })))
|
|
126
132
|
|
|
127
133
|
expect(result.current).toBe('buy soy milk')
|
|
128
134
|
expect(renderCount.val).toBe(2)
|
|
129
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
135
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
130
136
|
|
|
131
137
|
ReactTesting.act(() => store.setSignal(filter$, 't2'))
|
|
132
138
|
|
|
133
139
|
expect(result.current).toBe('buy eggs')
|
|
134
140
|
expect(renderCount.val).toBe(3)
|
|
135
|
-
expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
141
|
+
expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
|
|
136
142
|
}),
|
|
137
143
|
)
|
|
138
144
|
|
|
@@ -187,5 +193,50 @@ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
|
|
|
187
193
|
expect(result.current).toBe(1)
|
|
188
194
|
}),
|
|
189
195
|
)
|
|
196
|
+
|
|
197
|
+
Vitest.scopedLive('supports query builders directly', () =>
|
|
198
|
+
Effect.gen(function* () {
|
|
199
|
+
const { wrapper, store } = yield* makeTodoMvcReact({ strictMode })
|
|
200
|
+
|
|
201
|
+
store.commit(
|
|
202
|
+
events.todoCreated({ id: 't1', text: 'buy milk', completed: false }),
|
|
203
|
+
events.todoCreated({ id: 't2', text: 'buy eggs', completed: true }),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
const todosWhereIncomplete = tables.todos.where({ completed: false })
|
|
207
|
+
|
|
208
|
+
const { result } = ReactTesting.renderHook(() => store.useQuery(todosWhereIncomplete).map((todo) => todo.id), {
|
|
209
|
+
wrapper,
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
expect(result.current).toEqual(['t1'])
|
|
213
|
+
}),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
Vitest.scopedLive('union of different result types with useQuery', () =>
|
|
217
|
+
Effect.gen(function* () {
|
|
218
|
+
const { wrapper, store, renderCount } = yield* makeTodoMvcReact({ strictMode })
|
|
219
|
+
|
|
220
|
+
const str$ = signal('hello', { label: 'str' })
|
|
221
|
+
const num$ = signal(123, { label: 'num' })
|
|
222
|
+
|
|
223
|
+
const { result, rerender } = ReactTesting.renderHook(
|
|
224
|
+
(useNum: boolean) => {
|
|
225
|
+
renderCount.inc()
|
|
226
|
+
const query$ = React.useMemo(() => (useNum ? num$ : str$), [useNum])
|
|
227
|
+
return store.useQuery(query$)
|
|
228
|
+
},
|
|
229
|
+
{ wrapper, initialProps: false },
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
expect(result.current).toBe('hello')
|
|
233
|
+
expect(renderCount.val).toBe(1)
|
|
234
|
+
|
|
235
|
+
rerender(true)
|
|
236
|
+
|
|
237
|
+
expect(result.current).toBe(123)
|
|
238
|
+
expect(renderCount.val).toBe(2)
|
|
239
|
+
}),
|
|
240
|
+
)
|
|
190
241
|
},
|
|
191
242
|
)
|
package/src/useQuery.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
import { isQueryBuilder } from '@livestore/common'
|
|
1
2
|
import type { LiveQuery, LiveQueryDef, Store } from '@livestore/livestore'
|
|
2
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
extractStackInfoFromStackTrace,
|
|
5
|
+
isQueryable,
|
|
6
|
+
type Queryable,
|
|
7
|
+
queryDb,
|
|
8
|
+
type SignalDef,
|
|
9
|
+
StoreInternalsSymbol,
|
|
10
|
+
stackInfoToString,
|
|
11
|
+
} from '@livestore/livestore'
|
|
3
12
|
import type { LiveQueries } from '@livestore/livestore/internal'
|
|
4
13
|
import { deepEqual, indent, shouldNeverHappen } from '@livestore/utils'
|
|
5
14
|
import * as otel from '@opentelemetry/api'
|
|
@@ -21,15 +30,36 @@ import { useStateRefWithReactiveInput } from './utils/useStateRefWithReactiveInp
|
|
|
21
30
|
* }
|
|
22
31
|
* ```
|
|
23
32
|
*/
|
|
24
|
-
export const useQuery = <
|
|
25
|
-
|
|
33
|
+
export const useQuery = <TQueryable extends Queryable<any>>(
|
|
34
|
+
queryable: TQueryable,
|
|
26
35
|
options?: { store?: Store },
|
|
27
|
-
):
|
|
36
|
+
): Queryable.Result<TQueryable> => useQueryRef(queryable, options).valueRef.current
|
|
28
37
|
|
|
29
38
|
/**
|
|
39
|
+
* Like `useQuery`, but also returns a reference to the underlying LiveQuery instance.
|
|
40
|
+
*
|
|
41
|
+
* Usage
|
|
42
|
+
* - Accepts any `Queryable<TResult>`: a `LiveQueryDef`, `SignalDef`, a `LiveQuery` instance
|
|
43
|
+
* or a SQL `QueryBuilder`. Unions of queryables are supported and the result type is
|
|
44
|
+
* inferred via `Queryable.Result<TQueryable>`.
|
|
45
|
+
* - Creates an OpenTelemetry span per unique query, reusing it while the ref-counted
|
|
46
|
+
* resource is alive. The span name is updated once the dynamic label is known.
|
|
47
|
+
* - Manages a reference-counted resource under-the-hood so query instances are shared
|
|
48
|
+
* across re-renders and properly disposed once no longer referenced.
|
|
49
|
+
*
|
|
50
|
+
* Parameters
|
|
51
|
+
* - `queryable`: The query definition/instance/builder to run and subscribe to.
|
|
52
|
+
* - `options.store`: Optional store to use; by default the store from `LiveStoreContext` is used.
|
|
53
|
+
* - `options.otelContext`: Optional parent otel context for the query span.
|
|
54
|
+
* - `options.otelSpanName`: Optional explicit span name; otherwise derived from the query label.
|
|
55
|
+
*
|
|
56
|
+
* Returns
|
|
57
|
+
* - `valueRef`: A React ref whose `current` holds the latest query result. The type is
|
|
58
|
+
* `Queryable.Result<TQueryable>` with full inference for unions.
|
|
59
|
+
* - `queryRcRef`: The underlying reference-counted `LiveQuery` instance used by the store.
|
|
30
60
|
*/
|
|
31
|
-
export const useQueryRef = <
|
|
32
|
-
|
|
61
|
+
export const useQueryRef = <TQueryable extends Queryable<any>>(
|
|
62
|
+
queryable: TQueryable,
|
|
33
63
|
options?: {
|
|
34
64
|
store?: Store
|
|
35
65
|
/** Parent otel context for the query */
|
|
@@ -38,17 +68,50 @@ export const useQueryRef = <TQuery extends LiveQueryDef.Any>(
|
|
|
38
68
|
otelSpanName?: string
|
|
39
69
|
},
|
|
40
70
|
): {
|
|
41
|
-
valueRef: React.RefObject<
|
|
42
|
-
queryRcRef: LiveQueries.RcRef<LiveQuery<
|
|
71
|
+
valueRef: React.RefObject<Queryable.Result<TQueryable>>
|
|
72
|
+
queryRcRef: LiveQueries.RcRef<LiveQuery<Queryable.Result<TQueryable>>>
|
|
43
73
|
} => {
|
|
44
74
|
const store =
|
|
45
|
-
options?.store ??
|
|
46
|
-
// biome-ignore lint/correctness/useHookAtTopLevel: store is stable
|
|
75
|
+
options?.store ?? // biome-ignore lint/correctness/useHookAtTopLevel: store is stable
|
|
47
76
|
React.useContext(LiveStoreContext)?.store ??
|
|
48
77
|
shouldNeverHappen(`No store provided to useQuery`)
|
|
49
78
|
|
|
79
|
+
type TResult = Queryable.Result<TQueryable>
|
|
80
|
+
type NormalizedQueryable =
|
|
81
|
+
| { _tag: 'definition'; def: LiveQueryDef<TResult> | SignalDef<TResult> }
|
|
82
|
+
| { _tag: 'live-query'; query$: LiveQuery<TResult> }
|
|
83
|
+
|
|
84
|
+
const normalized = React.useMemo<NormalizedQueryable>(() => {
|
|
85
|
+
if (!isQueryable(queryable)) {
|
|
86
|
+
return shouldNeverHappen('useQuery expected a Queryable value')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (isQueryBuilder(queryable)) {
|
|
90
|
+
return { _tag: 'definition', def: queryDb(queryable) }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (
|
|
94
|
+
(queryable as LiveQueryDef<TResult> | SignalDef<TResult>)._tag === 'def' ||
|
|
95
|
+
(queryable as LiveQueryDef<TResult> | SignalDef<TResult>)._tag === 'signal-def'
|
|
96
|
+
) {
|
|
97
|
+
return { _tag: 'definition', def: queryable as LiveQueryDef<TResult> | SignalDef<TResult> }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { _tag: 'live-query', query$: queryable as LiveQuery<TResult> }
|
|
101
|
+
}, [queryable])
|
|
102
|
+
|
|
50
103
|
// It's important to use all "aspects" of a store instance here, otherwise we get unexpected cache mappings
|
|
51
|
-
const rcRefKey =
|
|
104
|
+
const rcRefKey = React.useMemo(() => {
|
|
105
|
+
const base = `${store.storeId}_${store.clientId}_${store.sessionId}`
|
|
106
|
+
|
|
107
|
+
if (normalized._tag === 'definition') {
|
|
108
|
+
return `${base}:def:${normalized.def.hash}`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return `${base}:instance:${normalized.query$.id}`
|
|
112
|
+
}, [normalized, store.clientId, store.sessionId, store.storeId])
|
|
113
|
+
|
|
114
|
+
const resourceLabel = normalized._tag === 'definition' ? normalized.def.label : normalized.query$.label
|
|
52
115
|
|
|
53
116
|
const stackInfo = React.useMemo(() => {
|
|
54
117
|
Error.stackTraceLimit = 10
|
|
@@ -60,17 +123,22 @@ export const useQueryRef = <TQuery extends LiveQueryDef.Any>(
|
|
|
60
123
|
const { queryRcRef, span, otelContext } = useRcResource(
|
|
61
124
|
rcRefKey,
|
|
62
125
|
() => {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
options?.
|
|
67
|
-
{ attributes: { label: queryDefLabel, firstStackInfo: JSON.stringify(stackInfo) } },
|
|
68
|
-
options?.otelContext ?? store.otel.queriesSpanContext,
|
|
126
|
+
const span = store[StoreInternalsSymbol].otel.tracer.startSpan(
|
|
127
|
+
options?.otelSpanName ?? `LiveStore:useQuery:${resourceLabel}`,
|
|
128
|
+
{ attributes: { label: resourceLabel, firstStackInfo: JSON.stringify(stackInfo) } },
|
|
129
|
+
options?.otelContext ?? store[StoreInternalsSymbol].otel.queriesSpanContext,
|
|
69
130
|
)
|
|
70
131
|
|
|
71
132
|
const otelContext = otel.trace.setSpan(otel.context.active(), span)
|
|
72
133
|
|
|
73
|
-
const queryRcRef =
|
|
134
|
+
const queryRcRef =
|
|
135
|
+
normalized._tag === 'definition'
|
|
136
|
+
? normalized.def.make(store[StoreInternalsSymbol].reactivityGraph.context!, otelContext)
|
|
137
|
+
: ({
|
|
138
|
+
value: normalized.query$,
|
|
139
|
+
deref: () => {},
|
|
140
|
+
rc: Number.POSITIVE_INFINITY,
|
|
141
|
+
} satisfies LiveQueries.RcRef<LiveQuery<TResult>>)
|
|
74
142
|
|
|
75
143
|
return { queryRcRef, span, otelContext }
|
|
76
144
|
},
|
|
@@ -83,7 +151,7 @@ export const useQueryRef = <TQuery extends LiveQueryDef.Any>(
|
|
|
83
151
|
// const queryRcRef.value.get()
|
|
84
152
|
// }
|
|
85
153
|
|
|
86
|
-
const query$ = queryRcRef.value as LiveQuery<
|
|
154
|
+
const query$ = queryRcRef.value as LiveQuery<TResult>
|
|
87
155
|
|
|
88
156
|
React.useDebugValue(`LiveStore:useQuery:${query$.id}:${query$.label}`)
|
|
89
157
|
// console.debug(`LiveStore:useQuery:${query$.id}:${query$.label}`)
|
|
@@ -119,7 +187,7 @@ Stack trace:
|
|
|
119
187
|
}, [otelContext, query$, stackInfo])
|
|
120
188
|
|
|
121
189
|
// We know the query has a result by the time we use it; so we can synchronously populate a default state
|
|
122
|
-
const [valueRef, setValue] = useStateRefWithReactiveInput<
|
|
190
|
+
const [valueRef, setValue] = useStateRefWithReactiveInput<TResult>(initialResult)
|
|
123
191
|
|
|
124
192
|
// TODO we probably need to change the order of `useEffect` calls, so we destroy the query at the end
|
|
125
193
|
// before calling the LS `onEffect` on it
|
|
@@ -133,8 +201,9 @@ Stack trace:
|
|
|
133
201
|
// so we're also updating the span name here.
|
|
134
202
|
span.updateName(options?.otelSpanName ?? `LiveStore:useQuery:${query$.label}`)
|
|
135
203
|
|
|
136
|
-
return store.subscribe(
|
|
137
|
-
|
|
204
|
+
return store.subscribe(
|
|
205
|
+
query$,
|
|
206
|
+
(newValue) => {
|
|
138
207
|
// NOTE: we return a reference to the result object within LiveStore;
|
|
139
208
|
// this implies that app code must not mutate the results, or else
|
|
140
209
|
// there may be weird reactivity bugs.
|
|
@@ -142,12 +211,14 @@ Stack trace:
|
|
|
142
211
|
setValue(newValue)
|
|
143
212
|
}
|
|
144
213
|
},
|
|
145
|
-
|
|
146
|
-
|
|
214
|
+
{
|
|
215
|
+
onUnsubsubscribe: () => {
|
|
216
|
+
query$.activeSubscriptions.delete(stackInfo)
|
|
217
|
+
},
|
|
218
|
+
label: query$.label,
|
|
219
|
+
otelContext,
|
|
147
220
|
},
|
|
148
|
-
|
|
149
|
-
otelContext,
|
|
150
|
-
})
|
|
221
|
+
)
|
|
151
222
|
}, [stackInfo, query$, setValue, store, valueRef, otelContext, span, options?.otelSpanName])
|
|
152
223
|
|
|
153
224
|
useRcResource(
|
|
@@ -2,7 +2,7 @@ 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(() => {
|