@livestore/react 0.3.0-dev.11 → 0.3.0-dev.5

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 (80) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/LiveStoreContext.d.ts +3 -5
  3. package/dist/LiveStoreContext.d.ts.map +1 -1
  4. package/dist/LiveStoreContext.js +3 -7
  5. package/dist/LiveStoreContext.js.map +1 -1
  6. package/dist/LiveStoreProvider.d.ts +1 -2
  7. package/dist/LiveStoreProvider.d.ts.map +1 -1
  8. package/dist/LiveStoreProvider.js +20 -5
  9. package/dist/LiveStoreProvider.js.map +1 -1
  10. package/dist/__tests__/fixture.d.ts +9 -6
  11. package/dist/__tests__/fixture.d.ts.map +1 -1
  12. package/dist/__tests__/fixture.js +7 -6
  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 +4 -5
  17. package/dist/experimental/components/LiveList.js.map +1 -1
  18. package/dist/mod.d.ts +1 -0
  19. package/dist/mod.d.ts.map +1 -1
  20. package/dist/mod.js +1 -0
  21. package/dist/mod.js.map +1 -1
  22. package/dist/useAtom.d.ts +2 -4
  23. package/dist/useAtom.d.ts.map +1 -1
  24. package/dist/useAtom.js +28 -32
  25. package/dist/useAtom.js.map +1 -1
  26. package/dist/useQuery.d.ts +3 -26
  27. package/dist/useQuery.d.ts.map +1 -1
  28. package/dist/useQuery.js +45 -60
  29. package/dist/useQuery.js.map +1 -1
  30. package/dist/useQuery.test.js +16 -70
  31. package/dist/useQuery.test.js.map +1 -1
  32. package/dist/useRow.d.ts +7 -10
  33. package/dist/useRow.d.ts.map +1 -1
  34. package/dist/useRow.js +19 -16
  35. package/dist/useRow.js.map +1 -1
  36. package/dist/useRow.test.js +96 -74
  37. package/dist/useRow.test.js.map +1 -1
  38. package/dist/useScopedQuery.d.ts +4 -10
  39. package/dist/useScopedQuery.d.ts.map +1 -1
  40. package/dist/useScopedQuery.js +52 -97
  41. package/dist/useScopedQuery.js.map +1 -1
  42. package/dist/useScopedQuery.test.js +12 -13
  43. package/dist/useScopedQuery.test.js.map +1 -1
  44. package/dist/utils/useStateRefWithReactiveInput.d.ts +1 -1
  45. package/dist/utils/useStateRefWithReactiveInput.d.ts.map +1 -1
  46. package/dist/utils/useStateRefWithReactiveInput.js.map +1 -1
  47. package/package.json +17 -18
  48. package/src/LiveStoreContext.ts +6 -10
  49. package/src/LiveStoreProvider.tsx +21 -7
  50. package/src/__snapshots__/useRow.test.tsx.snap +149 -337
  51. package/src/__tests__/fixture.tsx +10 -7
  52. package/src/experimental/components/LiveList.tsx +7 -8
  53. package/src/mod.ts +1 -0
  54. package/src/useAtom.ts +11 -22
  55. package/src/useQuery.test.tsx +67 -165
  56. package/src/useQuery.ts +54 -84
  57. package/src/useRow.test.tsx +163 -130
  58. package/src/useRow.ts +35 -32
  59. package/src/useScopedQuery.test.tsx +96 -0
  60. package/src/useScopedQuery.ts +142 -0
  61. package/src/utils/useStateRefWithReactiveInput.ts +1 -1
  62. package/dist/useRcRef.d.ts +0 -72
  63. package/dist/useRcRef.d.ts.map +0 -1
  64. package/dist/useRcRef.js +0 -146
  65. package/dist/useRcRef.js.map +0 -1
  66. package/dist/useRcRef.test.d.ts +0 -2
  67. package/dist/useRcRef.test.d.ts.map +0 -1
  68. package/dist/useRcRef.test.js +0 -128
  69. package/dist/useRcRef.test.js.map +0 -1
  70. package/dist/useRcResource.d.ts +0 -76
  71. package/dist/useRcResource.d.ts.map +0 -1
  72. package/dist/useRcResource.js +0 -150
  73. package/dist/useRcResource.js.map +0 -1
  74. package/dist/useRcResource.test.d.ts +0 -2
  75. package/dist/useRcResource.test.d.ts.map +0 -1
  76. package/dist/useRcResource.test.js +0 -122
  77. package/dist/useRcResource.test.js.map +0 -1
  78. package/src/__snapshots__/useQuery.test.tsx.snap +0 -2011
  79. package/src/useRcResource.test.tsx +0 -167
  80. package/src/useRcResource.ts +0 -180
@@ -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 } from '@livestore/livestore'
4
+ import { createStore, globalReactivityGraph, makeReactivityGraph } 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 userInfo = DbSchema.table(
45
+ export const AppComponentSchema = DbSchema.table(
46
46
  'UserInfo',
47
47
  {
48
48
  username: DbSchema.text({ default: '' }),
@@ -59,16 +59,18 @@ export const AppRouterSchema = DbSchema.table(
59
59
  { isSingleton: true, deriveMutations: true },
60
60
  )
61
61
 
62
- export const tables = { todos, app, userInfo, AppRouterSchema }
62
+ export const tables = { todos, app, AppComponentSchema, AppRouterSchema }
63
63
  export const schema = makeSchema({ tables })
64
64
 
65
65
  export const makeTodoMvcReact = ({
66
66
  otelTracer,
67
67
  otelContext,
68
- strictMode,
68
+ useGlobalReactivityGraph = true,
69
+ strictMode = process.env.REACT_STRICT_MODE !== undefined,
69
70
  }: {
70
71
  otelTracer?: otel.Tracer
71
72
  otelContext?: otel.Context
73
+ useGlobalReactivityGraph?: boolean
72
74
  strictMode?: boolean
73
75
  } = {}) =>
74
76
  Effect.gen(function* () {
@@ -87,10 +89,13 @@ export const makeTodoMvcReact = ({
87
89
  }
88
90
  }
89
91
 
92
+ const reactivityGraph = useGlobalReactivityGraph ? globalReactivityGraph : makeReactivityGraph()
93
+
90
94
  const store = yield* createStore({
91
95
  schema,
92
96
  storeId: 'default',
93
97
  adapter: makeInMemoryAdapter(),
98
+ reactivityGraph,
94
99
  debug: { instanceId: 'test' },
95
100
  })
96
101
 
@@ -107,7 +112,5 @@ export const makeTodoMvcReact = ({
107
112
  </MaybeStrictMode>
108
113
  )
109
114
 
110
- const renderCount = makeRenderCount()
111
-
112
- return { wrapper, store, renderCount }
115
+ return { wrapper, store, reactivityGraph, makeRenderCount, strictMode }
113
116
  }).pipe(provideOtel({ parentSpanContext: otelContext, otelTracer }))
@@ -1,8 +1,9 @@
1
- import type { LiveQueryDef } from '@livestore/livestore'
1
+ import type { LiveQuery } 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'
6
7
 
7
8
  /*
8
9
  TODO:
@@ -11,7 +12,7 @@ TODO:
11
12
  */
12
13
 
13
14
  export type LiveListProps<TItem> = {
14
- items$: LiveQueryDef<ReadonlyArray<TItem>>
15
+ items$: LiveQuery<ReadonlyArray<TItem>>
15
16
  // TODO refactor render-flag to allow for transition animations on add/remove
16
17
  renderItem: (item: TItem, opts: { index: number; isInitialListRender: boolean }) => React.ReactNode
17
18
  /** Needs to be unique across all list items */
@@ -31,16 +32,14 @@ export const LiveList = <TItem,>({ items$, renderItem, getKey }: LiveListProps<T
31
32
 
32
33
  React.useEffect(() => setHasMounted(true), [])
33
34
 
34
- const keys = useQuery(computed((get) => get(items$).map(getKey)))
35
+ const keysCb = React.useCallback(() => computed((get) => get(items$).map(getKey)), [getKey, items$])
36
+ const keys = useScopedQuery(keysCb, 'fixed')
35
37
  const arr = React.useMemo(
36
38
  () =>
37
39
  keys.map(
38
40
  (key) =>
39
41
  // TODO figure out a way so that `item$` returns an ordered lookup map to more efficiently find the item by key
40
- [
41
- key,
42
- computed((get) => get(items$).find((item) => getKey(item, 0) === key)!) as LiveQueryDef<TItem>,
43
- ] as const,
42
+ [key, computed((get) => get(items$).find((item) => getKey(item, 0) === key)!) as LiveQuery<TItem>] as const,
44
43
  ),
45
44
  [getKey, items$, keys],
46
45
  )
@@ -66,7 +65,7 @@ const ItemWrapper = <TItem,>({
66
65
  renderItem,
67
66
  }: {
68
67
  itemKey: string | number
69
- item$: LiveQueryDef<TItem>
68
+ item$: LiveQuery<TItem>
70
69
  opts: { index: number; isInitialListRender: boolean }
71
70
  renderItem: (item: TItem, opts: { index: number; isInitialListRender: boolean }) => React.ReactNode
72
71
  }) => {
package/src/mod.ts CHANGED
@@ -1,6 +1,7 @@
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'
4
5
  export { useStackInfo } from './utils/stack-info.js'
5
6
  export {
6
7
  useRow,
package/src/useAtom.ts CHANGED
@@ -1,8 +1,7 @@
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 { GetResult, LiveQueryDef, Store } from '@livestore/livestore'
5
- import { shouldNeverHappen } from '@livestore/utils'
4
+ import type { LiveQuery } from '@livestore/livestore'
6
5
  import React from 'react'
7
6
 
8
7
  import { useStore } from './LiveStoreContext.js'
@@ -11,27 +10,18 @@ import type { Dispatch, SetStateAction } from './useRow.js'
11
10
 
12
11
  export const useAtom = <
13
12
  // TODO also support colJsonValue
14
- TQuery extends LiveQueryDef<any, QueryInfo.Row | QueryInfo.Col>,
13
+ TQuery extends LiveQuery<any, QueryInfo.Row | QueryInfo.Col>,
15
14
  >(
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
- }
15
+ query$: TQuery,
16
+ ): [value: TQuery['__result!'], setValue: Dispatch<SetStateAction<Partial<TQuery['__result!']>>>] => {
17
+ const query$Ref = useQueryRef(query$)
28
18
 
29
19
  const { store } = useStore()
30
20
 
31
21
  // TODO make API equivalent to useRow
32
- const setValue = React.useMemo<Dispatch<SetStateAction<Partial<GetResult<TQuery>>>>>(
33
- () => (newValueOrFn: any) => {
34
- const newValue = typeof newValueOrFn === 'function' ? newValueOrFn(queryRef.valueRef.current) : newValueOrFn
22
+ const setValue = React.useMemo<Dispatch<SetStateAction<TQuery['__result!']>>>(() => {
23
+ return (newValueOrFn: any) => {
24
+ const newValue = typeof newValueOrFn === 'function' ? newValueOrFn(query$Ref.current) : newValueOrFn
35
25
  const table = query$.queryInfo.table as DbSchema.TableDef &
36
26
  DerivedMutationHelperFns<SqliteDsl.Columns, DbSchema.TableOptions>
37
27
 
@@ -55,9 +45,8 @@ export const useAtom = <
55
45
  )
56
46
  }
57
47
  }
58
- },
59
- [query$.queryInfo, queryRef.valueRef, store],
60
- )
48
+ }
49
+ }, [query$.queryInfo, query$Ref, store])
61
50
 
62
- return [queryRef.valueRef.current, setValue]
51
+ return [query$Ref.current, setValue]
63
52
  }
@@ -1,180 +1,82 @@
1
- import { makeRef, queryDb } from '@livestore/livestore'
2
- import * as LiveStore from '@livestore/livestore'
3
- import { RG } from '@livestore/livestore/internal/testing-utils'
1
+ import { queryDb } from '@livestore/livestore'
4
2
  import { Effect, Schema } from '@livestore/utils/effect'
5
- import { Vitest } from '@livestore/utils/node-vitest'
6
- import * as ReactTesting from '@testing-library/react'
3
+ import { renderHook } from '@testing-library/react'
7
4
  import React from 'react'
8
- // @ts-expect-error no types
9
- import * as ReactWindow from 'react-window'
10
- import { expect } from 'vitest'
5
+ import { describe, expect, it } from 'vitest'
11
6
 
12
7
  import { makeTodoMvcReact, tables, todos } from './__tests__/fixture.js'
13
8
  import * as LiveStoreReact from './mod.js'
14
- import { __resetUseRcResourceCache } from './useRcResource.js'
15
9
 
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
- })
10
+ describe('useQuery', () => {
11
+ it('simple', () =>
12
+ Effect.gen(function* () {
13
+ const { wrapper, store, makeRenderCount } = yield* makeTodoMvcReact()
23
14
 
24
- Vitest.scopedLive('simple', () =>
25
- Effect.gen(function* () {
26
- const { wrapper, store, renderCount } = yield* makeTodoMvcReact({ strictMode })
15
+ const renderCount = makeRenderCount()
27
16
 
28
- const allTodos$ = queryDb({ query: `select * from todos`, schema: Schema.Array(tables.todos.schema) })
17
+ const allTodos$ = queryDb({ query: `select * from todos`, schema: Schema.Array(tables.todos.schema) })
29
18
 
30
- const { result } = ReactTesting.renderHook(
31
- () => {
32
- renderCount.inc()
19
+ const { result } = renderHook(
20
+ () => {
21
+ renderCount.inc()
33
22
 
34
- return LiveStoreReact.useQuery(allTodos$)
35
- },
36
- { wrapper },
37
- )
23
+ return LiveStoreReact.useQuery(allTodos$)
24
+ },
25
+ { wrapper },
26
+ )
38
27
 
39
- expect(result.current.length).toBe(0)
40
- expect(renderCount.val).toBe(1)
41
- expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
28
+ expect(result.current.length).toBe(0)
29
+ expect(renderCount.val).toBe(1)
42
30
 
43
- console.log('before mutation')
31
+ React.act(() => store.mutate(todos.insert({ id: 't1', text: 'buy milk', completed: false })))
44
32
 
45
- ReactTesting.act(() => store.mutate(todos.insert({ id: 't1', text: 'buy milk', completed: false })))
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))
46
37
 
47
- console.log('after mutation')
38
+ it('same `useQuery` hook invoked with different queries', () =>
39
+ Effect.gen(function* () {
40
+ const { wrapper, store, makeRenderCount } = yield* makeTodoMvcReact()
48
41
 
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
- )
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
+ })
package/src/useQuery.ts CHANGED
@@ -1,51 +1,38 @@
1
- import type { GetResult, LiveQuery, LiveQueryDef, LiveQueryDefAny, RcRef, Store } from '@livestore/livestore'
1
+ import type { GetResult, LiveQueryAny } 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'
9
8
  import { originalStackLimit } from './utils/stack-info.js'
10
9
  import { useStateRefWithReactiveInput } from './utils/useStateRefWithReactiveInput.js'
11
10
 
12
11
  /**
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
- * ```
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.
22
14
  */
23
- export const useQuery = <TQuery extends LiveQueryDefAny>(
24
- queryDef: TQuery,
25
- options?: { store?: Store },
26
- ): GetResult<TQuery> => useQueryRef(queryDef, options).valueRef.current
27
15
 
28
- type GetQueryInfo<TQuery extends LiveQueryDefAny> =
29
- TQuery extends LiveQueryDef<infer _1, infer TQueryInfo> ? TQueryInfo : never
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.
20
+ */
21
+ const spanAlreadyStartedCache = new Map<LiveQueryAny, { span: otel.Span; otelContext: otel.Context }>()
22
+
23
+ export const useQuery = <TQuery extends LiveQueryAny>(query: TQuery): GetResult<TQuery> => useQueryRef(query).current
30
24
 
31
25
  /**
26
+ *
32
27
  */
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}`
28
+ export const useQueryRef = <TQuery extends LiveQueryAny>(
29
+ query$: TQuery,
30
+ parentOtelContext?: otel.Context,
31
+ ): React.MutableRefObject<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}`)
49
36
 
50
37
  const stackInfo = React.useMemo(() => {
51
38
  Error.stackTraceLimit = 10
@@ -55,42 +42,31 @@ export const useQueryRef = <TQuery extends LiveQueryDefAny>(
55
42
  return extractStackInfoFromStackTrace(stack)
56
43
  }, [])
57
44
 
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
- )
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
68
49
 
69
- const otelContext = otel.trace.setSpan(otel.context.active(), span)
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
+ )
70
55
 
71
- const queryRcRef = queryDef.make(store.reactivityGraph.context!, otelContext)
56
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
72
57
 
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>>
58
+ spanAlreadyStartedCache.set(query$, { span, otelContext })
80
59
 
81
- React.useDebugValue(`LiveStore:useQuery:${query$.id}:${query$.label}`)
82
- // console.debug(`LiveStore:useQuery:${query$.id}:${query$.label}`)
60
+ return { span, otelContext }
61
+ }, [parentOtelContext, query$, stackInfo, store.otel.queriesSpanContext, store.otel.tracer])
83
62
 
84
63
  const initialResult = React.useMemo(() => {
85
64
  try {
86
- return query$.run({
87
- otelContext,
88
- debugRefreshReason: {
89
- _tag: 'react',
90
- api: 'useQuery',
91
- label: `useQuery:initial-run:${query$.label}`,
92
- stackInfo,
93
- },
65
+ return query$.run(otelContext, {
66
+ _tag: 'react',
67
+ api: 'useQuery',
68
+ label: query$.label,
69
+ stackInfo,
94
70
  })
95
71
  } catch (cause: any) {
96
72
  throw new Error(
@@ -113,20 +89,25 @@ Stack trace:
113
89
  // We know the query has a result by the time we use it; so we can synchronously populate a default state
114
90
  const [valueRef, setValue] = useStateRefWithReactiveInput<GetResult<TQuery>>(initialResult)
115
91
 
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
92
+ React.useEffect(
93
+ () => () => {
94
+ spanAlreadyStartedCache.delete(query$)
95
+ span.end()
96
+ },
97
+ [query$, span],
98
+ )
118
99
 
119
100
  // Subscribe to future updates for this query
120
101
  React.useEffect(() => {
121
- // TODO double check whether we still need `activeSubscriptions`
122
102
  query$.activeSubscriptions.add(stackInfo)
123
103
 
124
104
  // Dynamic queries only set their actual label after they've been run the first time,
125
105
  // so we're also updating the span name here.
126
- span.updateName(options?.otelSpanName ?? `LiveStore:useQuery:${query$.label}`)
106
+ span.updateName(`LiveStore:useQuery:${query$.label}`)
127
107
 
128
- return store.subscribe(query$, {
129
- onUpdate: (newValue) => {
108
+ return store.subscribe(
109
+ query$,
110
+ (newValue) => {
130
111
  // NOTE: we return a reference to the result object within LiveStore;
131
112
  // this implies that app code must not mutate the results, or else
132
113
  // there may be weird reactivity bugs.
@@ -134,23 +115,12 @@ Stack trace:
134
115
  setValue(newValue)
135
116
  }
136
117
  },
137
- onUnsubsubscribe: () => {
118
+ () => {
138
119
  query$.activeSubscriptions.delete(stackInfo)
139
120
  },
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
- )
121
+ { label: query$.label, otelContext },
122
+ )
123
+ }, [stackInfo, query$, setValue, store, valueRef, otelContext, span])
154
124
 
155
- return { valueRef, queryRcRef }
125
+ return valueRef
156
126
  }