@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.
Files changed (91) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/LiveStoreProvider.d.ts +6 -7
  3. package/dist/LiveStoreProvider.d.ts.map +1 -1
  4. package/dist/LiveStoreProvider.js +39 -24
  5. package/dist/LiveStoreProvider.js.map +1 -1
  6. package/dist/LiveStoreProvider.test.js +7 -7
  7. package/dist/LiveStoreProvider.test.js.map +1 -1
  8. package/dist/__tests__/fixture.d.ts +34 -12
  9. package/dist/__tests__/fixture.d.ts.map +1 -1
  10. package/dist/__tests__/fixture.js +13 -5
  11. package/dist/__tests__/fixture.js.map +1 -1
  12. package/dist/experimental/components/LiveList.js +1 -1
  13. package/dist/experimental/mod.d.ts +1 -0
  14. package/dist/experimental/mod.d.ts.map +1 -1
  15. package/dist/experimental/mod.js +1 -0
  16. package/dist/experimental/mod.js.map +1 -1
  17. package/dist/experimental/multi-store/StoreRegistry.d.ts +61 -0
  18. package/dist/experimental/multi-store/StoreRegistry.d.ts.map +1 -0
  19. package/dist/experimental/multi-store/StoreRegistry.js +275 -0
  20. package/dist/experimental/multi-store/StoreRegistry.js.map +1 -0
  21. package/dist/experimental/multi-store/StoreRegistry.test.d.ts +2 -0
  22. package/dist/experimental/multi-store/StoreRegistry.test.d.ts.map +1 -0
  23. package/dist/experimental/multi-store/StoreRegistry.test.js +464 -0
  24. package/dist/experimental/multi-store/StoreRegistry.test.js.map +1 -0
  25. package/dist/experimental/multi-store/StoreRegistryContext.d.ts +10 -0
  26. package/dist/experimental/multi-store/StoreRegistryContext.d.ts.map +1 -0
  27. package/dist/experimental/multi-store/StoreRegistryContext.js +15 -0
  28. package/dist/experimental/multi-store/StoreRegistryContext.js.map +1 -0
  29. package/dist/experimental/multi-store/mod.d.ts +6 -0
  30. package/dist/experimental/multi-store/mod.d.ts.map +1 -0
  31. package/dist/experimental/multi-store/mod.js +6 -0
  32. package/dist/experimental/multi-store/mod.js.map +1 -0
  33. package/dist/experimental/multi-store/storeOptions.d.ts +4 -0
  34. package/dist/experimental/multi-store/storeOptions.d.ts.map +1 -0
  35. package/dist/experimental/multi-store/storeOptions.js +4 -0
  36. package/dist/experimental/multi-store/storeOptions.js.map +1 -0
  37. package/dist/experimental/multi-store/types.d.ts +44 -0
  38. package/dist/experimental/multi-store/types.d.ts.map +1 -0
  39. package/dist/experimental/multi-store/types.js +2 -0
  40. package/dist/experimental/multi-store/types.js.map +1 -0
  41. package/dist/experimental/multi-store/useStore.d.ts +11 -0
  42. package/dist/experimental/multi-store/useStore.d.ts.map +1 -0
  43. package/dist/experimental/multi-store/useStore.js +21 -0
  44. package/dist/experimental/multi-store/useStore.js.map +1 -0
  45. package/dist/experimental/multi-store/useStore.test.d.ts +2 -0
  46. package/dist/experimental/multi-store/useStore.test.d.ts.map +1 -0
  47. package/dist/experimental/multi-store/useStore.test.js +144 -0
  48. package/dist/experimental/multi-store/useStore.test.js.map +1 -0
  49. package/dist/mod.d.ts +1 -1
  50. package/dist/mod.d.ts.map +1 -1
  51. package/dist/mod.js.map +1 -1
  52. package/dist/useClientDocument.d.ts +10 -13
  53. package/dist/useClientDocument.d.ts.map +1 -1
  54. package/dist/useClientDocument.js +4 -5
  55. package/dist/useClientDocument.js.map +1 -1
  56. package/dist/useClientDocument.test.js +29 -7
  57. package/dist/useClientDocument.test.js.map +1 -1
  58. package/dist/useQuery.d.ts +28 -6
  59. package/dist/useQuery.d.ts.map +1 -1
  60. package/dist/useQuery.js +63 -18
  61. package/dist/useQuery.js.map +1 -1
  62. package/dist/useQuery.test.js +35 -11
  63. package/dist/useQuery.test.js.map +1 -1
  64. package/dist/useRcResource.test.js +1 -1
  65. package/dist/useStore.d.ts +2 -1
  66. package/dist/useStore.d.ts.map +1 -1
  67. package/dist/useStore.js +1 -1
  68. package/dist/useStore.js.map +1 -1
  69. package/package.json +14 -14
  70. package/src/LiveStoreProvider.test.tsx +7 -7
  71. package/src/LiveStoreProvider.tsx +58 -45
  72. package/src/__snapshots__/useClientDocument.test.tsx.snap +208 -100
  73. package/src/__snapshots__/useQuery.test.tsx.snap +400 -128
  74. package/src/__tests__/fixture.tsx +23 -24
  75. package/src/experimental/components/LiveList.tsx +1 -1
  76. package/src/experimental/mod.ts +1 -0
  77. package/src/experimental/multi-store/StoreRegistry.test.ts +631 -0
  78. package/src/experimental/multi-store/StoreRegistry.ts +347 -0
  79. package/src/experimental/multi-store/StoreRegistryContext.tsx +23 -0
  80. package/src/experimental/multi-store/mod.ts +5 -0
  81. package/src/experimental/multi-store/storeOptions.ts +8 -0
  82. package/src/experimental/multi-store/types.ts +55 -0
  83. package/src/experimental/multi-store/useStore.test.tsx +197 -0
  84. package/src/experimental/multi-store/useStore.ts +34 -0
  85. package/src/mod.ts +2 -1
  86. package/src/useClientDocument.test.tsx +105 -75
  87. package/src/useClientDocument.ts +23 -13
  88. package/src/useQuery.test.tsx +62 -11
  89. package/src/useQuery.ts +98 -27
  90. package/src/useRcResource.test.tsx +1 -1
  91. 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 { getSimplifiedRootSpan } from '@livestore/livestore/internal/testing-utils'
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.js'
14
- import type * as LiveStoreReact from './mod.js'
15
- import { __resetUseRcResourceCache } from './useRcResource.js'
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.describe('otel', () => {
228
- it.each([{ strictMode: true }, { strictMode: false }])(
229
- 'should update the data based on component key strictMode=%s',
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
- const provider = new BasicTracerProvider({
234
- spanProcessors: [new SimpleSpanProcessor(exporter)],
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
- const otelTracer = provider.getTracer(`testing-${strictMode ? 'strict' : 'non-strict'}`)
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 span = otelTracer.startSpan('test-root')
240
- const otelContext = otel.trace.setSpan(otel.context.active(), span)
279
+ const { result, rerender, unmount } = ReactTesting.renderHook(
280
+ (userId: string) => {
281
+ renderCount.inc()
241
282
 
242
- await Effect.gen(function* () {
243
- const { wrapper, store, renderCount } = yield* makeTodoMvcReact({
244
- otelContext,
245
- otelTracer,
246
- strictMode,
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
- const { result, rerender, unmount } = ReactTesting.renderHook(
250
- (userId: string) => {
251
- renderCount.inc()
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
- const [state, setState, id] = store.useClientDocument(tables.userInfo, userId)
254
- return { state, setState, id }
255
- },
256
- { wrapper, initialProps: 'u1' },
257
- )
328
+ expect(getSimplifiedRootSpan(exporter, 'createStore', mapAttributes)).toMatchSnapshot()
329
+ expect(getAllSimplifiedRootSpans(exporter, 'LiveStore:commit', mapAttributes)).toMatchSnapshot()
258
330
 
259
- expect(result.current.id).toBe('u1')
260
- expect(result.current.state.username).toBe('')
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
  })
@@ -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 UseRowResult<TTableDef extends State.SQLite.ClientDocumentTableDef.TraitAny> = [
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
- { partialSet: boolean; default: { id: string | SessionIdSymbol; value: any } }
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
- ): UseRowResult<TTableDef>
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
- { partialSet: boolean; default: { id: string | SessionIdSymbol | undefined; value: any } }
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
- ): UseRowResult<TTableDef>
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
- ): UseRowResult<TTableDef> => {
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.clientSession.sessionId : id
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 SetStateAction<S> = Partial<S> | ((previousValue: S) => Partial<S>)
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
- SetStateAction<TTableDef['Value']>
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>) => {
@@ -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.js'
14
- import { __resetUseRcResourceCache } from './useRcResource.js'
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('1: after first render')
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('2: after first commit')
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('3: after forced rerender')
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 { extractStackInfoFromStackTrace, stackInfoToString } from '@livestore/livestore'
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 = <TQuery extends LiveQueryDef.Any>(
25
- queryDef: TQuery,
33
+ export const useQuery = <TQueryable extends Queryable<any>>(
34
+ queryable: TQueryable,
26
35
  options?: { store?: Store },
27
- ): LiveQueries.GetResult<TQuery> => useQueryRef(queryDef, options).valueRef.current
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 = <TQuery extends LiveQueryDef.Any>(
32
- queryDef: TQuery,
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<LiveQueries.GetResult<TQuery>>
42
- queryRcRef: LiveQueries.RcRef<LiveQuery<LiveQueries.GetResult<TQuery>>>
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 = `${store.storeId}_${store.clientId}_${store.sessionId}_${queryDef.hash}`
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 queryDefLabel = queryDef.label
64
-
65
- const span = store.otel.tracer.startSpan(
66
- options?.otelSpanName ?? `LiveStore:useQuery:${queryDefLabel}`,
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 = queryDef.make(store.reactivityGraph.context!, otelContext)
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<LiveQueries.GetResult<TQuery>>
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<LiveQueries.GetResult<TQuery>>(initialResult)
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(query$, {
137
- onUpdate: (newValue) => {
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
- onUnsubsubscribe: () => {
146
- query$.activeSubscriptions.delete(stackInfo)
214
+ {
215
+ onUnsubsubscribe: () => {
216
+ query$.activeSubscriptions.delete(stackInfo)
217
+ },
218
+ label: query$.label,
219
+ otelContext,
147
220
  },
148
- label: query$.label,
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.js'
5
+ import { __resetUseRcResourceCache, useRcResource } from './useRcResource.ts'
6
6
 
7
7
  describe.each([{ strictMode: true }, { strictMode: false }])('useRcResource (strictMode=%s)', ({ strictMode }) => {
8
8
  beforeEach(() => {