@livestore/react 0.3.0-dev.10 → 0.3.0-dev.12

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 (76) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/LiveStoreContext.d.ts +5 -3
  3. package/dist/LiveStoreContext.d.ts.map +1 -1
  4. package/dist/LiveStoreContext.js +7 -3
  5. package/dist/LiveStoreContext.js.map +1 -1
  6. package/dist/LiveStoreProvider.d.ts +5 -2
  7. package/dist/LiveStoreProvider.d.ts.map +1 -1
  8. package/dist/LiveStoreProvider.js +2 -17
  9. package/dist/LiveStoreProvider.js.map +1 -1
  10. package/dist/__tests__/fixture.d.ts +6 -8
  11. package/dist/__tests__/fixture.d.ts.map +1 -1
  12. package/dist/__tests__/fixture.js +6 -7
  13. package/dist/__tests__/fixture.js.map +1 -1
  14. package/dist/experimental/components/LiveList.d.ts +2 -2
  15. package/dist/experimental/components/LiveList.d.ts.map +1 -1
  16. package/dist/experimental/components/LiveList.js +5 -4
  17. package/dist/experimental/components/LiveList.js.map +1 -1
  18. package/dist/mod.d.ts +0 -1
  19. package/dist/mod.d.ts.map +1 -1
  20. package/dist/mod.js +0 -1
  21. package/dist/mod.js.map +1 -1
  22. package/dist/useAtom.d.ts +4 -2
  23. package/dist/useAtom.d.ts.map +1 -1
  24. package/dist/useAtom.js +32 -28
  25. package/dist/useAtom.js.map +1 -1
  26. package/dist/useQuery.d.ts +26 -3
  27. package/dist/useQuery.d.ts.map +1 -1
  28. package/dist/useQuery.js +60 -45
  29. package/dist/useQuery.js.map +1 -1
  30. package/dist/useQuery.test.js +70 -16
  31. package/dist/useQuery.test.js.map +1 -1
  32. package/dist/useRcRef.d.ts +72 -0
  33. package/dist/useRcRef.d.ts.map +1 -0
  34. package/dist/useRcRef.js +146 -0
  35. package/dist/useRcRef.js.map +1 -0
  36. package/dist/useRcRef.test.d.ts +2 -0
  37. package/dist/useRcRef.test.d.ts.map +1 -0
  38. package/dist/useRcRef.test.js +128 -0
  39. package/dist/useRcRef.test.js.map +1 -0
  40. package/dist/useRcResource.d.ts +76 -0
  41. package/dist/useRcResource.d.ts.map +1 -0
  42. package/dist/useRcResource.js +150 -0
  43. package/dist/useRcResource.js.map +1 -0
  44. package/dist/useRcResource.test.d.ts +2 -0
  45. package/dist/useRcResource.test.d.ts.map +1 -0
  46. package/dist/useRcResource.test.js +122 -0
  47. package/dist/useRcResource.test.js.map +1 -0
  48. package/dist/useRow.d.ts +10 -7
  49. package/dist/useRow.d.ts.map +1 -1
  50. package/dist/useRow.js +23 -22
  51. package/dist/useRow.js.map +1 -1
  52. package/dist/useRow.test.js +62 -80
  53. package/dist/useRow.test.js.map +1 -1
  54. package/dist/useScopedQuery.d.ts +10 -4
  55. package/dist/useScopedQuery.d.ts.map +1 -1
  56. package/dist/useScopedQuery.js +96 -52
  57. package/dist/useScopedQuery.js.map +1 -1
  58. package/dist/useScopedQuery.test.js +13 -12
  59. package/dist/useScopedQuery.test.js.map +1 -1
  60. package/package.json +6 -6
  61. package/src/LiveStoreContext.ts +10 -6
  62. package/src/LiveStoreProvider.tsx +3 -19
  63. package/src/__snapshots__/useQuery.test.tsx.snap +2011 -0
  64. package/src/__snapshots__/useRow.test.tsx.snap +335 -142
  65. package/src/__tests__/fixture.tsx +6 -9
  66. package/src/experimental/components/LiveList.tsx +8 -7
  67. package/src/mod.ts +0 -1
  68. package/src/useAtom.ts +22 -11
  69. package/src/useQuery.test.tsx +165 -67
  70. package/src/useQuery.ts +84 -54
  71. package/src/useRcResource.test.tsx +167 -0
  72. package/src/useRcResource.ts +180 -0
  73. package/src/useRow.test.tsx +73 -107
  74. package/src/useRow.ts +42 -40
  75. package/src/useScopedQuery.test.tsx +0 -96
  76. package/src/useScopedQuery.ts +0 -143
@@ -1,7 +1,7 @@
1
1
  import { provideOtel } from '@livestore/common'
2
2
  import { DbSchema, makeSchema } from '@livestore/common/schema'
3
3
  import type { LiveStoreContextRunning } from '@livestore/livestore'
4
- import { createStore, globalReactivityGraph, makeReactivityGraph } from '@livestore/livestore'
4
+ import { createStore } from '@livestore/livestore'
5
5
  import { Effect } from '@livestore/utils/effect'
6
6
  import { makeInMemoryAdapter } from '@livestore/web'
7
7
  import type * as otel from '@opentelemetry/api'
@@ -42,7 +42,7 @@ export const app = DbSchema.table(
42
42
  { isSingleton: true },
43
43
  )
44
44
 
45
- export const AppComponentSchema = DbSchema.table(
45
+ export const userInfo = DbSchema.table(
46
46
  'UserInfo',
47
47
  {
48
48
  username: DbSchema.text({ default: '' }),
@@ -59,18 +59,16 @@ export const AppRouterSchema = DbSchema.table(
59
59
  { isSingleton: true, deriveMutations: true },
60
60
  )
61
61
 
62
- export const tables = { todos, app, AppComponentSchema, AppRouterSchema }
62
+ export const tables = { todos, app, userInfo, AppRouterSchema }
63
63
  export const schema = makeSchema({ tables })
64
64
 
65
65
  export const makeTodoMvcReact = ({
66
66
  otelTracer,
67
67
  otelContext,
68
- useGlobalReactivityGraph = true,
69
68
  strictMode,
70
69
  }: {
71
70
  otelTracer?: otel.Tracer
72
71
  otelContext?: otel.Context
73
- useGlobalReactivityGraph?: boolean
74
72
  strictMode?: boolean
75
73
  } = {}) =>
76
74
  Effect.gen(function* () {
@@ -89,13 +87,10 @@ export const makeTodoMvcReact = ({
89
87
  }
90
88
  }
91
89
 
92
- const reactivityGraph = useGlobalReactivityGraph ? globalReactivityGraph : makeReactivityGraph()
93
-
94
90
  const store = yield* createStore({
95
91
  schema,
96
92
  storeId: 'default',
97
93
  adapter: makeInMemoryAdapter(),
98
- reactivityGraph,
99
94
  debug: { instanceId: 'test' },
100
95
  })
101
96
 
@@ -112,5 +107,7 @@ export const makeTodoMvcReact = ({
112
107
  </MaybeStrictMode>
113
108
  )
114
109
 
115
- return { wrapper, store, reactivityGraph, makeRenderCount }
110
+ const renderCount = makeRenderCount()
111
+
112
+ return { wrapper, store, renderCount }
116
113
  }).pipe(provideOtel({ parentSpanContext: otelContext, otelTracer }))
@@ -1,9 +1,8 @@
1
- import type { LiveQuery } from '@livestore/livestore'
1
+ import type { LiveQueryDef } from '@livestore/livestore'
2
2
  import { computed } from '@livestore/livestore'
3
3
  import React from 'react'
4
4
 
5
5
  import { useQuery } from '../../useQuery.js'
6
- import { useScopedQuery } from '../../useScopedQuery.js'
7
6
 
8
7
  /*
9
8
  TODO:
@@ -12,7 +11,7 @@ TODO:
12
11
  */
13
12
 
14
13
  export type LiveListProps<TItem> = {
15
- items$: LiveQuery<ReadonlyArray<TItem>>
14
+ items$: LiveQueryDef<ReadonlyArray<TItem>>
16
15
  // TODO refactor render-flag to allow for transition animations on add/remove
17
16
  renderItem: (item: TItem, opts: { index: number; isInitialListRender: boolean }) => React.ReactNode
18
17
  /** Needs to be unique across all list items */
@@ -32,14 +31,16 @@ export const LiveList = <TItem,>({ items$, renderItem, getKey }: LiveListProps<T
32
31
 
33
32
  React.useEffect(() => setHasMounted(true), [])
34
33
 
35
- const keysCb = React.useCallback(() => computed((get) => get(items$).map(getKey)), [getKey, items$])
36
- const keys = useScopedQuery(keysCb, 'fixed')
34
+ const keys = useQuery(computed((get) => get(items$).map(getKey)))
37
35
  const arr = React.useMemo(
38
36
  () =>
39
37
  keys.map(
40
38
  (key) =>
41
39
  // TODO figure out a way so that `item$` returns an ordered lookup map to more efficiently find the item by key
42
- [key, computed((get) => get(items$).find((item) => getKey(item, 0) === key)!) as LiveQuery<TItem>] as const,
40
+ [
41
+ key,
42
+ computed((get) => get(items$).find((item) => getKey(item, 0) === key)!) as LiveQueryDef<TItem>,
43
+ ] as const,
43
44
  ),
44
45
  [getKey, items$, keys],
45
46
  )
@@ -65,7 +66,7 @@ const ItemWrapper = <TItem,>({
65
66
  renderItem,
66
67
  }: {
67
68
  itemKey: string | number
68
- item$: LiveQuery<TItem>
69
+ item$: LiveQueryDef<TItem>
69
70
  opts: { index: number; isInitialListRender: boolean }
70
71
  renderItem: (item: TItem, opts: { index: number; isInitialListRender: boolean }) => React.ReactNode
71
72
  }) => {
package/src/mod.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  export { LiveStoreContext, useStore } from './LiveStoreContext.js'
2
2
  export { LiveStoreProvider } from './LiveStoreProvider.js'
3
3
  export { useQuery } from './useQuery.js'
4
- export { useScopedQuery } from './useScopedQuery.js'
5
4
  export { useStackInfo } from './utils/stack-info.js'
6
5
  export {
7
6
  useRow,
package/src/useAtom.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { DerivedMutationHelperFns, QueryInfo } from '@livestore/common'
2
2
  import type { DbSchema } from '@livestore/common/schema'
3
3
  import type { SqliteDsl } from '@livestore/db-schema'
4
- import type { LiveQuery } from '@livestore/livestore'
4
+ import type { GetResult, LiveQueryDef, Store } from '@livestore/livestore'
5
+ import { shouldNeverHappen } from '@livestore/utils'
5
6
  import React from 'react'
6
7
 
7
8
  import { useStore } from './LiveStoreContext.js'
@@ -10,18 +11,27 @@ import type { Dispatch, SetStateAction } from './useRow.js'
10
11
 
11
12
  export const useAtom = <
12
13
  // TODO also support colJsonValue
13
- TQuery extends LiveQuery<any, QueryInfo.Row | QueryInfo.Col>,
14
+ TQuery extends LiveQueryDef<any, QueryInfo.Row | QueryInfo.Col>,
14
15
  >(
15
- query$: TQuery,
16
- ): [value: TQuery['__result!'], setValue: Dispatch<SetStateAction<Partial<TQuery['__result!']>>>] => {
17
- const query$Ref = useQueryRef(query$)
16
+ queryDef: TQuery,
17
+ options?: {
18
+ store?: Store
19
+ },
20
+ ): [value: GetResult<TQuery>, setValue: Dispatch<SetStateAction<Partial<GetResult<TQuery>>>>] => {
21
+ const queryRef = useQueryRef(queryDef, { store: options?.store })
22
+ const query$ = queryRef.queryRcRef.value
23
+
24
+ // @ts-expect-error runtime check
25
+ if (query$.queryInfo._tag === 'None') {
26
+ shouldNeverHappen(`Can't useAtom with a query that has no queryInfo`, queryDef)
27
+ }
18
28
 
19
29
  const { store } = useStore()
20
30
 
21
31
  // TODO make API equivalent to useRow
22
- const setValue = React.useMemo<Dispatch<SetStateAction<TQuery['__result!']>>>(() => {
23
- return (newValueOrFn: any) => {
24
- const newValue = typeof newValueOrFn === 'function' ? newValueOrFn(query$Ref.current) : newValueOrFn
32
+ const setValue = React.useMemo<Dispatch<SetStateAction<Partial<GetResult<TQuery>>>>>(
33
+ () => (newValueOrFn: any) => {
34
+ const newValue = typeof newValueOrFn === 'function' ? newValueOrFn(queryRef.valueRef.current) : newValueOrFn
25
35
  const table = query$.queryInfo.table as DbSchema.TableDef &
26
36
  DerivedMutationHelperFns<SqliteDsl.Columns, DbSchema.TableOptions>
27
37
 
@@ -45,8 +55,9 @@ export const useAtom = <
45
55
  )
46
56
  }
47
57
  }
48
- }
49
- }, [query$.queryInfo, query$Ref, store])
58
+ },
59
+ [query$.queryInfo, queryRef.valueRef, store],
60
+ )
50
61
 
51
- return [query$Ref.current, setValue]
62
+ return [queryRef.valueRef.current, setValue]
52
63
  }
@@ -1,82 +1,180 @@
1
- import { queryDb } from '@livestore/livestore'
1
+ import { makeRef, queryDb } from '@livestore/livestore'
2
+ import * as LiveStore from '@livestore/livestore'
3
+ import { RG } from '@livestore/livestore/internal/testing-utils'
2
4
  import { Effect, Schema } from '@livestore/utils/effect'
3
- import { renderHook } from '@testing-library/react'
5
+ import { Vitest } from '@livestore/utils/node-vitest'
6
+ import * as ReactTesting from '@testing-library/react'
4
7
  import React from 'react'
5
- import { describe, expect, it } from 'vitest'
8
+ // @ts-expect-error no types
9
+ import * as ReactWindow from 'react-window'
10
+ import { expect } from 'vitest'
6
11
 
7
12
  import { makeTodoMvcReact, tables, todos } from './__tests__/fixture.js'
8
13
  import * as LiveStoreReact from './mod.js'
14
+ import { __resetUseRcResourceCache } from './useRcResource.js'
9
15
 
10
- describe('useQuery', () => {
11
- it('simple', () =>
12
- Effect.gen(function* () {
13
- const { wrapper, store, makeRenderCount } = yield* makeTodoMvcReact()
16
+ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
17
+ 'useQuery (strictMode=%s)',
18
+ ({ strictMode }) => {
19
+ Vitest.afterEach(() => {
20
+ RG.__resetIds()
21
+ __resetUseRcResourceCache()
22
+ })
14
23
 
15
- const renderCount = makeRenderCount()
24
+ Vitest.scopedLive('simple', () =>
25
+ Effect.gen(function* () {
26
+ const { wrapper, store, renderCount } = yield* makeTodoMvcReact({ strictMode })
16
27
 
17
- const allTodos$ = queryDb({ query: `select * from todos`, schema: Schema.Array(tables.todos.schema) })
28
+ const allTodos$ = queryDb({ query: `select * from todos`, schema: Schema.Array(tables.todos.schema) })
18
29
 
19
- const { result } = renderHook(
20
- () => {
21
- renderCount.inc()
30
+ const { result } = ReactTesting.renderHook(
31
+ () => {
32
+ renderCount.inc()
22
33
 
23
- return LiveStoreReact.useQuery(allTodos$)
24
- },
25
- { wrapper },
26
- )
34
+ return LiveStoreReact.useQuery(allTodos$)
35
+ },
36
+ { wrapper },
37
+ )
27
38
 
28
- expect(result.current.length).toBe(0)
29
- expect(renderCount.val).toBe(1)
39
+ expect(result.current.length).toBe(0)
40
+ expect(renderCount.val).toBe(1)
41
+ expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
30
42
 
31
- React.act(() => store.mutate(todos.insert({ id: 't1', text: 'buy milk', completed: false })))
43
+ console.log('before mutation')
32
44
 
33
- expect(result.current.length).toBe(1)
34
- expect(result.current[0]!.text).toBe('buy milk')
35
- expect(renderCount.val).toBe(2)
36
- }).pipe(Effect.scoped, Effect.tapCauseLogPretty, Effect.runPromise))
45
+ ReactTesting.act(() => store.mutate(todos.insert({ id: 't1', text: 'buy milk', completed: false })))
37
46
 
38
- it('same `useQuery` hook invoked with different queries', () =>
39
- Effect.gen(function* () {
40
- const { wrapper, store, makeRenderCount } = yield* makeTodoMvcReact()
47
+ console.log('after mutation')
41
48
 
42
- const renderCount = makeRenderCount()
43
-
44
- const todo1$ = queryDb(
45
- { query: `select * from todos where id = 't1'`, schema: Schema.Array(tables.todos.schema) },
46
- { label: 'libraryTracksView1' },
47
- )
48
- const todo2$ = queryDb(
49
- { query: `select * from todos where id = 't2'`, schema: Schema.Array(tables.todos.schema) },
50
- { label: 'libraryTracksView2' },
51
- )
52
-
53
- store.mutate(
54
- todos.insert({ id: 't1', text: 'buy milk', completed: false }),
55
- todos.insert({ id: 't2', text: 'buy eggs', completed: false }),
56
- )
57
-
58
- const { result, rerender } = renderHook(
59
- (todoId: string) => {
60
- renderCount.inc()
61
-
62
- const query$ = React.useMemo(() => (todoId === 't1' ? todo1$ : todo2$), [todoId])
63
-
64
- return LiveStoreReact.useQuery(query$)[0]!.text
65
- },
66
- { wrapper, initialProps: 't1' },
67
- )
68
-
69
- expect(result.current).toBe('buy milk')
70
- expect(renderCount.val).toBe(1)
71
-
72
- React.act(() => store.mutate(todos.update({ where: { id: 't1' }, values: { text: 'buy soy milk' } })))
73
-
74
- expect(result.current).toBe('buy soy milk')
75
- expect(renderCount.val).toBe(2)
76
-
77
- rerender('t2')
78
-
79
- expect(result.current).toBe('buy eggs')
80
- expect(renderCount.val).toBe(3)
81
- }).pipe(Effect.scoped, Effect.tapCauseLogPretty, Effect.runPromise))
82
- })
49
+ expect(result.current.length).toBe(1)
50
+ expect(result.current[0]!.text).toBe('buy milk')
51
+ expect(renderCount.val).toBe(2)
52
+ expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
53
+ }),
54
+ )
55
+
56
+ Vitest.scopedLive('same `useQuery` hook invoked with different queries', () =>
57
+ Effect.gen(function* () {
58
+ const { wrapper, store, renderCount } = yield* makeTodoMvcReact({ strictMode })
59
+
60
+ const todo1$ = queryDb(
61
+ { query: `select * from todos where id = 't1'`, schema: Schema.Array(tables.todos.schema) },
62
+ { label: 'libraryTracksView1' },
63
+ )
64
+ const todo2$ = queryDb(
65
+ { query: `select * from todos where id = 't2'`, schema: Schema.Array(tables.todos.schema) },
66
+ { label: 'libraryTracksView2' },
67
+ )
68
+
69
+ store.mutate(
70
+ todos.insert({ id: 't1', text: 'buy milk', completed: false }),
71
+ todos.insert({ id: 't2', text: 'buy eggs', completed: false }),
72
+ )
73
+
74
+ const { result, rerender } = ReactTesting.renderHook(
75
+ (todoId: string) => {
76
+ renderCount.inc()
77
+
78
+ const query$ = React.useMemo(() => (todoId === 't1' ? todo1$ : todo2$), [todoId])
79
+
80
+ return LiveStoreReact.useQuery(query$)[0]!.text
81
+ },
82
+ { wrapper, initialProps: 't1' },
83
+ )
84
+
85
+ expect(result.current).toBe('buy milk')
86
+ expect(renderCount.val).toBe(1)
87
+ expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot('1: after first render')
88
+
89
+ ReactTesting.act(() => store.mutate(todos.update({ where: { id: 't1' }, values: { text: 'buy soy milk' } })))
90
+
91
+ expect(result.current).toBe('buy soy milk')
92
+ expect(renderCount.val).toBe(2)
93
+ expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot('2: after first mutation')
94
+
95
+ rerender('t2')
96
+
97
+ expect(result.current).toBe('buy eggs')
98
+ expect(renderCount.val).toBe(3)
99
+ expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot('3: after forced rerender')
100
+ }),
101
+ )
102
+
103
+ Vitest.scopedLive('filtered dependency query', () =>
104
+ Effect.gen(function* () {
105
+ const { wrapper, store, renderCount } = yield* makeTodoMvcReact({ strictMode })
106
+
107
+ const filter$ = makeRef('t1', { label: 'id-filter' })
108
+
109
+ const todo$ = queryDb((get) => tables.todos.query.where('id', get(filter$)), { label: 'todo' })
110
+
111
+ store.mutate(
112
+ todos.insert({ id: 't1', text: 'buy milk', completed: false }),
113
+ todos.insert({ id: 't2', text: 'buy eggs', completed: false }),
114
+ )
115
+
116
+ const { result } = ReactTesting.renderHook(
117
+ () => {
118
+ renderCount.inc()
119
+
120
+ return LiveStoreReact.useQuery(todo$)[0]!.text
121
+ },
122
+ { wrapper },
123
+ )
124
+
125
+ expect(result.current).toBe('buy milk')
126
+ expect(renderCount.val).toBe(1)
127
+ expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
128
+
129
+ ReactTesting.act(() => store.mutate(todos.update({ where: { id: 't1' }, values: { text: 'buy soy milk' } })))
130
+
131
+ expect(result.current).toBe('buy soy milk')
132
+ expect(renderCount.val).toBe(2)
133
+ expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
134
+
135
+ ReactTesting.act(() => store.setRef(filter$, 't2'))
136
+
137
+ expect(result.current).toBe('buy eggs')
138
+ expect(renderCount.val).toBe(3)
139
+ expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
140
+ }),
141
+ )
142
+
143
+ // NOTE this test covers some special react lifecyle paths which I couldn't easily reproduce without react-window
144
+ // it basically causes a "query swap" in the `useMemo` and both a `useEffect` cleanup call.
145
+ // To handle this properly we introduced the `_tag: 'destroyed'` state in the `spanAlreadyStartedCache`.
146
+ Vitest.scopedLive('should work for a list with react-window', () =>
147
+ Effect.gen(function* () {
148
+ const { wrapper } = yield* makeTodoMvcReact({ strictMode })
149
+
150
+ const ListWrapper: React.FC<{ numItems: number }> = ({ numItems }) => {
151
+ return (
152
+ <ReactWindow.FixedSizeList
153
+ height={100}
154
+ width={100}
155
+ itemSize={10}
156
+ itemCount={numItems}
157
+ itemData={Array.from({ length: numItems }, (_, i) => i).reverse()}
158
+ >
159
+ {ListItem}
160
+ </ReactWindow.FixedSizeList>
161
+ )
162
+ }
163
+
164
+ const ListItem: React.FC<{ data: ReadonlyArray<number>; index: number }> = ({ data: ids, index }) => {
165
+ const id = ids[index]!
166
+ const res = LiveStoreReact.useQuery(LiveStore.computed(() => id, { label: `ListItem.${id}`, deps: id }))
167
+ return <div role="listitem">{res}</div>
168
+ }
169
+
170
+ const renderResult = ReactTesting.render(<ListWrapper numItems={1} />, { wrapper })
171
+
172
+ expect(renderResult.container.textContent).toBe('0')
173
+
174
+ renderResult.rerender(<ListWrapper numItems={2} />)
175
+
176
+ expect(renderResult.container.textContent).toBe('10')
177
+ }),
178
+ )
179
+ },
180
+ )
package/src/useQuery.ts CHANGED
@@ -1,38 +1,51 @@
1
- import type { GetResult, LiveQueryAny } from '@livestore/livestore'
1
+ import type { GetResult, LiveQuery, LiveQueryDef, LiveQueryDefAny, RcRef, Store } from '@livestore/livestore'
2
2
  import { extractStackInfoFromStackTrace, stackInfoToString } from '@livestore/livestore'
3
3
  import { deepEqual, indent } from '@livestore/utils'
4
4
  import * as otel from '@opentelemetry/api'
5
5
  import React from 'react'
6
6
 
7
7
  import { useStore } from './LiveStoreContext.js'
8
+ import { useRcResource } from './useRcResource.js'
8
9
  import { originalStackLimit } from './utils/stack-info.js'
9
10
  import { useStateRefWithReactiveInput } from './utils/useStateRefWithReactiveInput.js'
10
11
 
11
12
  /**
12
- * NOTE Some folks have suggested to use `React.useSyncExternalStore`, however, it's not doing anything special
13
- * for what's needed here, so we handle everything manually.
14
- */
15
-
16
- /**
17
- * This is needed because the `React.useMemo` call below, can sometimes be called multiple times 🤷,
18
- * so we need to "cache" the fact that we've already started a span for this component.
19
- * The map entry is being removed again in the `React.useEffect` call below.
13
+ * Returns the result of a query and subscribes to future updates.
14
+ *
15
+ * Example:
16
+ * ```tsx
17
+ * const App = () => {
18
+ * const todos = useQuery(queryDb(tables.todos.query.where({ complete: true })))
19
+ * return <div>{todos.map((todo) => <div key={todo.id}>{todo.title}</div>)}</div>
20
+ * }
21
+ * ```
20
22
  */
21
- const spanAlreadyStartedCache = new Map<LiveQueryAny, { span: otel.Span; otelContext: otel.Context }>()
23
+ export const useQuery = <TQuery extends LiveQueryDefAny>(
24
+ queryDef: TQuery,
25
+ options?: { store?: Store },
26
+ ): GetResult<TQuery> => useQueryRef(queryDef, options).valueRef.current
22
27
 
23
- export const useQuery = <TQuery extends LiveQueryAny>(query: TQuery): GetResult<TQuery> => useQueryRef(query).current
28
+ type GetQueryInfo<TQuery extends LiveQueryDefAny> =
29
+ TQuery extends LiveQueryDef<infer _1, infer TQueryInfo> ? TQueryInfo : never
24
30
 
25
31
  /**
26
- *
27
32
  */
28
- export const useQueryRef = <TQuery extends LiveQueryAny>(
29
- query$: TQuery,
30
- parentOtelContext?: otel.Context,
31
- ): React.RefObject<GetResult<TQuery>> => {
32
- const { store } = useStore()
33
-
34
- React.useDebugValue(`LiveStore:useQuery:${query$.id}:${query$.label}`)
35
- // console.debug(`LiveStore:useQuery:${query$.id}:${query$.label}`)
33
+ export const useQueryRef = <TQuery extends LiveQueryDefAny>(
34
+ queryDef: TQuery,
35
+ options?: {
36
+ store?: Store
37
+ /** Parent otel context for the query */
38
+ otelContext?: otel.Context
39
+ /** The name of the span to use for the query */
40
+ otelSpanName?: string
41
+ },
42
+ ): {
43
+ valueRef: React.RefObject<GetResult<TQuery>>
44
+ queryRcRef: RcRef<LiveQuery<GetResult<TQuery>, GetQueryInfo<TQuery>>>
45
+ } => {
46
+ const { store } = useStore({ store: options?.store })
47
+
48
+ const rcRefKey = `${store.storeId}_${queryDef.hash}`
36
49
 
37
50
  const stackInfo = React.useMemo(() => {
38
51
  Error.stackTraceLimit = 10
@@ -42,31 +55,42 @@ export const useQueryRef = <TQuery extends LiveQueryAny>(
42
55
  return extractStackInfoFromStackTrace(stack)
43
56
  }, [])
44
57
 
45
- // The following `React.useMemo` and `React.useEffect` calls are used to start and end a span for the lifetime of this component.
46
- const { span, otelContext } = React.useMemo(() => {
47
- const existingSpan = spanAlreadyStartedCache.get(query$)
48
- if (existingSpan !== undefined) return existingSpan
58
+ const { queryRcRef, span, otelContext } = useRcResource(
59
+ rcRefKey,
60
+ () => {
61
+ const queryDefLabel = queryDef.label
62
+
63
+ const span = store.otel.tracer.startSpan(
64
+ options?.otelSpanName ?? `LiveStore:useQuery:${queryDefLabel}`,
65
+ { attributes: { label: queryDefLabel, firstStackInfo: JSON.stringify(stackInfo) } },
66
+ options?.otelContext ?? store.otel.queriesSpanContext,
67
+ )
49
68
 
50
- const span = store.otel.tracer.startSpan(
51
- `LiveStore:useQuery:${query$.label}`,
52
- { attributes: { label: query$.label, stackInfo: JSON.stringify(stackInfo) } },
53
- parentOtelContext ?? store.otel.queriesSpanContext,
54
- )
69
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
55
70
 
56
- const otelContext = otel.trace.setSpan(otel.context.active(), span)
71
+ const queryRcRef = queryDef.make(store.reactivityGraph.context!, otelContext)
57
72
 
58
- spanAlreadyStartedCache.set(query$, { span, otelContext })
73
+ return { queryRcRef, span, otelContext }
74
+ },
75
+ // We need to keep the queryRcRef alive a bit longer, so we have a second `useRcResource` below
76
+ // which takes care of disposing the queryRcRef
77
+ () => {},
78
+ )
79
+ const query$ = queryRcRef.value as LiveQuery<GetResult<TQuery>, GetQueryInfo<TQuery>>
59
80
 
60
- return { span, otelContext }
61
- }, [parentOtelContext, query$, stackInfo, store.otel.queriesSpanContext, store.otel.tracer])
81
+ React.useDebugValue(`LiveStore:useQuery:${query$.id}:${query$.label}`)
82
+ // console.debug(`LiveStore:useQuery:${query$.id}:${query$.label}`)
62
83
 
63
84
  const initialResult = React.useMemo(() => {
64
85
  try {
65
- return query$.run(otelContext, {
66
- _tag: 'react',
67
- api: 'useQuery',
68
- label: query$.label,
69
- stackInfo,
86
+ return query$.run({
87
+ otelContext,
88
+ debugRefreshReason: {
89
+ _tag: 'react',
90
+ api: 'useQuery',
91
+ label: `useQuery:initial-run:${query$.label}`,
92
+ stackInfo,
93
+ },
70
94
  })
71
95
  } catch (cause: any) {
72
96
  throw new Error(
@@ -89,25 +113,20 @@ Stack trace:
89
113
  // We know the query has a result by the time we use it; so we can synchronously populate a default state
90
114
  const [valueRef, setValue] = useStateRefWithReactiveInput<GetResult<TQuery>>(initialResult)
91
115
 
92
- React.useEffect(
93
- () => () => {
94
- spanAlreadyStartedCache.delete(query$)
95
- span.end()
96
- },
97
- [query$, span],
98
- )
116
+ // TODO we probably need to change the order of `useEffect` calls, so we destroy the query at the end
117
+ // before calling the LS `onEffect` on it
99
118
 
100
119
  // Subscribe to future updates for this query
101
120
  React.useEffect(() => {
121
+ // TODO double check whether we still need `activeSubscriptions`
102
122
  query$.activeSubscriptions.add(stackInfo)
103
123
 
104
124
  // Dynamic queries only set their actual label after they've been run the first time,
105
125
  // so we're also updating the span name here.
106
- span.updateName(`LiveStore:useQuery:${query$.label}`)
126
+ span.updateName(options?.otelSpanName ?? `LiveStore:useQuery:${query$.label}`)
107
127
 
108
- return store.subscribe(
109
- query$,
110
- (newValue) => {
128
+ return store.subscribe(query$, {
129
+ onUpdate: (newValue) => {
111
130
  // NOTE: we return a reference to the result object within LiveStore;
112
131
  // this implies that app code must not mutate the results, or else
113
132
  // there may be weird reactivity bugs.
@@ -115,12 +134,23 @@ Stack trace:
115
134
  setValue(newValue)
116
135
  }
117
136
  },
118
- () => {
137
+ onUnsubsubscribe: () => {
119
138
  query$.activeSubscriptions.delete(stackInfo)
120
139
  },
121
- { label: query$.label, otelContext },
122
- )
123
- }, [stackInfo, query$, setValue, store, valueRef, otelContext, span])
140
+ label: query$.label,
141
+ otelContext,
142
+ })
143
+ }, [stackInfo, query$, setValue, store, valueRef, otelContext, span, options?.otelSpanName])
144
+
145
+ useRcResource(
146
+ rcRefKey,
147
+ () => ({ queryRcRef, span }),
148
+ ({ queryRcRef, span }) => {
149
+ // console.debug('deref', queryRcRef.value.id, queryRcRef.value.label)
150
+ queryRcRef.deref()
151
+ span.end()
152
+ },
153
+ )
124
154
 
125
- return valueRef
155
+ return { valueRef, queryRcRef }
126
156
  }