@livestore/react 0.3.0-dev.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/LiveStoreContext.d.ts +10 -4
  3. package/dist/LiveStoreContext.d.ts.map +1 -1
  4. package/dist/LiveStoreContext.js +1 -11
  5. package/dist/LiveStoreContext.js.map +1 -1
  6. package/dist/LiveStoreProvider.d.ts +29 -12
  7. package/dist/LiveStoreProvider.d.ts.map +1 -1
  8. package/dist/LiveStoreProvider.js +84 -55
  9. package/dist/LiveStoreProvider.js.map +1 -1
  10. package/dist/LiveStoreProvider.test.js +80 -29
  11. package/dist/LiveStoreProvider.test.js.map +1 -1
  12. package/dist/__tests__/fixture.d.ts +122 -556
  13. package/dist/__tests__/fixture.d.ts.map +1 -1
  14. package/dist/__tests__/fixture.js +71 -30
  15. package/dist/__tests__/fixture.js.map +1 -1
  16. package/dist/experimental/components/LiveList.d.ts +2 -2
  17. package/dist/experimental/components/LiveList.d.ts.map +1 -1
  18. package/dist/experimental/components/LiveList.js +10 -6
  19. package/dist/experimental/components/LiveList.js.map +1 -1
  20. package/dist/mod.d.ts +4 -5
  21. package/dist/mod.d.ts.map +1 -1
  22. package/dist/mod.js +4 -5
  23. package/dist/mod.js.map +1 -1
  24. package/dist/useClientDocument.d.ts +61 -0
  25. package/dist/useClientDocument.d.ts.map +1 -0
  26. package/dist/useClientDocument.js +79 -0
  27. package/dist/useClientDocument.js.map +1 -0
  28. package/dist/useClientDocument.test.d.ts +2 -0
  29. package/dist/useClientDocument.test.d.ts.map +1 -0
  30. package/dist/useClientDocument.test.js +175 -0
  31. package/dist/useClientDocument.test.js.map +1 -0
  32. package/dist/useQuery.d.ts +25 -3
  33. package/dist/useQuery.d.ts.map +1 -1
  34. package/dist/useQuery.js +67 -47
  35. package/dist/useQuery.js.map +1 -1
  36. package/dist/useQuery.test.d.ts +1 -1
  37. package/dist/useQuery.test.d.ts.map +1 -1
  38. package/dist/useQuery.test.js +86 -24
  39. package/dist/useQuery.test.js.map +1 -1
  40. package/dist/useRcResource.d.ts +76 -0
  41. package/dist/useRcResource.d.ts.map +1 -0
  42. package/dist/useRcResource.js +152 -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/useStore.d.ts +9 -0
  49. package/dist/useStore.d.ts.map +1 -0
  50. package/dist/useStore.js +28 -0
  51. package/dist/useStore.js.map +1 -0
  52. package/dist/utils/useStateRefWithReactiveInput.d.ts.map +1 -1
  53. package/package.json +19 -13
  54. package/src/LiveStoreContext.ts +11 -16
  55. package/src/LiveStoreProvider.test.tsx +176 -37
  56. package/src/LiveStoreProvider.tsx +156 -81
  57. package/src/__snapshots__/useClientDocument.test.tsx.snap +613 -0
  58. package/src/__snapshots__/useQuery.test.tsx.snap +2011 -0
  59. package/src/__tests__/fixture.tsx +74 -47
  60. package/src/experimental/components/LiveList.tsx +10 -7
  61. package/src/mod.ts +5 -6
  62. package/src/useClientDocument.test.tsx +306 -0
  63. package/src/useClientDocument.ts +157 -0
  64. package/src/useQuery.test.tsx +182 -71
  65. package/src/useQuery.ts +95 -58
  66. package/src/useRcResource.test.tsx +167 -0
  67. package/src/useRcResource.ts +182 -0
  68. package/src/useStore.ts +36 -0
  69. package/dist/useAtom.d.ts +0 -5
  70. package/dist/useAtom.d.ts.map +0 -1
  71. package/dist/useAtom.js +0 -38
  72. package/dist/useAtom.js.map +0 -1
  73. package/dist/useRow.d.ts +0 -50
  74. package/dist/useRow.d.ts.map +0 -1
  75. package/dist/useRow.js +0 -93
  76. package/dist/useRow.js.map +0 -1
  77. package/dist/useRow.test.d.ts +0 -2
  78. package/dist/useRow.test.d.ts.map +0 -1
  79. package/dist/useRow.test.js +0 -202
  80. package/dist/useRow.test.js.map +0 -1
  81. package/dist/useScopedQuery.d.ts +0 -33
  82. package/dist/useScopedQuery.d.ts.map +0 -1
  83. package/dist/useScopedQuery.js +0 -87
  84. package/dist/useScopedQuery.js.map +0 -1
  85. package/dist/useScopedQuery.test.d.ts +0 -2
  86. package/dist/useScopedQuery.test.d.ts.map +0 -1
  87. package/dist/useScopedQuery.test.js +0 -60
  88. package/dist/useScopedQuery.test.js.map +0 -1
  89. package/src/__snapshots__/useRow.test.tsx.snap +0 -360
  90. package/src/useAtom.ts +0 -52
  91. package/src/useRow.test.tsx +0 -344
  92. package/src/useRow.ts +0 -188
  93. package/src/useScopedQuery.test.tsx +0 -96
  94. package/src/useScopedQuery.ts +0 -143
  95. package/tsconfig.json +0 -20
  96. package/vitest.config.js +0 -17
@@ -0,0 +1,157 @@
1
+ import type { RowQuery } from '@livestore/common'
2
+ import { SessionIdSymbol } from '@livestore/common'
3
+ import { State } from '@livestore/common/schema'
4
+ import type { LiveQuery, LiveQueryDef, Store } from '@livestore/livestore'
5
+ import { queryDb } from '@livestore/livestore'
6
+ import { shouldNeverHappen } from '@livestore/utils'
7
+ import React from 'react'
8
+
9
+ import { LiveStoreContext } from './LiveStoreContext.js'
10
+ import { useQueryRef } from './useQuery.js'
11
+
12
+ export type UseRowResult<TTableDef extends State.SQLite.ClientDocumentTableDef.TraitAny> = [
13
+ row: TTableDef['Value'],
14
+ setRow: StateSetters<TTableDef>,
15
+ id: string,
16
+ query$: LiveQuery<TTableDef['Value']>,
17
+ ]
18
+
19
+ /**
20
+ * Similar to `React.useState` but returns a tuple of `[state, setState, id, query$]` for a given table where ...
21
+ *
22
+ * - `state` is the current value of the row (fully decoded according to the table schema)
23
+ * - `setState` is a function that can be used to update the document
24
+ * - `id` is the id of the document
25
+ * - `query$` is a `LiveQuery` that e.g. can be used to subscribe to changes to the document
26
+ *
27
+ * `useClientDocument` only works for client-document tables:
28
+ *
29
+ * ```tsx
30
+ * const MyState = State.SQLite.clientDocument({
31
+ * name: 'MyState',
32
+ * schema: Schema.Struct({
33
+ * showSidebar: Schema.Boolean,
34
+ * }),
35
+ * default: { id: SessionIdSymbol, value: { showSidebar: true } },
36
+ * })
37
+ *
38
+ * const MyComponent = () => {
39
+ * const [{ showSidebar }, setState] = useClientDocument(MyState)
40
+ * return (
41
+ * <div onClick={() => setState({ showSidebar: !showSidebar })}>
42
+ * {showSidebar ? 'Sidebar is open' : 'Sidebar is closed'}
43
+ * </div>
44
+ * )
45
+ * }
46
+ * ```
47
+ *
48
+ * If the table has a default id, `useClientDocument` can be called without an `id` argument. Otherwise, the `id` argument is required.
49
+ */
50
+ export const useClientDocument: {
51
+ // case: with default id
52
+ <
53
+ TTableDef extends State.SQLite.ClientDocumentTableDef.Trait<
54
+ any,
55
+ any,
56
+ any,
57
+ { partialSet: boolean; default: { id: string | SessionIdSymbol; value: any } }
58
+ >,
59
+ >(
60
+ table: TTableDef,
61
+ id?: State.SQLite.ClientDocumentTableDef.DefaultIdType<TTableDef> | SessionIdSymbol,
62
+ options?: Partial<RowQuery.GetOrCreateOptions<TTableDef>>,
63
+ ): UseRowResult<TTableDef>
64
+
65
+ // case: no default id → id arg is required
66
+ <
67
+ TTableDef extends State.SQLite.ClientDocumentTableDef.Trait<
68
+ any,
69
+ any,
70
+ any,
71
+ { partialSet: boolean; default: { id: string | SessionIdSymbol | undefined; value: any } }
72
+ >,
73
+ >(
74
+ table: TTableDef,
75
+ // TODO adjust so it works with arbitrary primary keys or unique constraints
76
+ id: State.SQLite.ClientDocumentTableDef.DefaultIdType<TTableDef> | string | SessionIdSymbol,
77
+ options?: Partial<RowQuery.GetOrCreateOptions<TTableDef>>,
78
+ ): UseRowResult<TTableDef>
79
+ } = <TTableDef extends State.SQLite.ClientDocumentTableDef.Any>(
80
+ table: TTableDef,
81
+ idOrOptions?: string | SessionIdSymbol,
82
+ options_?: Partial<RowQuery.GetOrCreateOptions<TTableDef>>,
83
+ storeArg?: { store?: Store },
84
+ ): UseRowResult<TTableDef> => {
85
+ const id =
86
+ typeof idOrOptions === 'string' || idOrOptions === SessionIdSymbol
87
+ ? idOrOptions
88
+ : table[State.SQLite.ClientDocumentTableDefSymbol].options.default.id
89
+
90
+ const options: Partial<RowQuery.GetOrCreateOptions<TTableDef>> | undefined =
91
+ typeof idOrOptions === 'string' || idOrOptions === SessionIdSymbol ? options_ : idOrOptions
92
+
93
+ const { default: defaultValues } = options ?? {}
94
+
95
+ React.useMemo(() => validateTableOptions(table), [table])
96
+
97
+ const tableName = table.sqliteDef.name
98
+
99
+ const store =
100
+ storeArg?.store ??
101
+ // eslint-disable-next-line react-hooks/rules-of-hooks
102
+ React.useContext(LiveStoreContext)?.store ??
103
+ shouldNeverHappen(`No store provided to useClientDocument`)
104
+
105
+ // console.debug('useClientDocument', tableName, id)
106
+
107
+ const idStr: string = id === SessionIdSymbol ? store.clientSession.sessionId : id
108
+
109
+ type QueryDef = LiveQueryDef<TTableDef['Value']>
110
+ const queryDef: QueryDef = React.useMemo(
111
+ () =>
112
+ queryDb(table.get(id!, { default: defaultValues! }), {
113
+ deps: [idStr!, table.sqliteDef.name, JSON.stringify(defaultValues)],
114
+ }),
115
+ [table, id, defaultValues, idStr],
116
+ )
117
+
118
+ const queryRef = useQueryRef(queryDef, {
119
+ otelSpanName: `LiveStore:useClientDocument:${tableName}:${idStr}`,
120
+ store: storeArg?.store,
121
+ })
122
+
123
+ const setState = React.useMemo<StateSetters<TTableDef>>(
124
+ () => (newValueOrFn: TTableDef['Value']) => {
125
+ const newValue = typeof newValueOrFn === 'function' ? newValueOrFn(queryRef.valueRef.current) : newValueOrFn
126
+ if (queryRef.valueRef.current === newValue) return
127
+
128
+ store.commit(table.set(removeUndefinedValues(newValue), id as any))
129
+ },
130
+ [id, queryRef.valueRef, store, table],
131
+ )
132
+
133
+ return [queryRef.valueRef.current, setState, idStr, queryRef.queryRcRef.value]
134
+ }
135
+
136
+ export type Dispatch<A> = (action: A) => void
137
+ export type SetStateAction<S> = Partial<S> | ((previousValue: S) => Partial<S>)
138
+
139
+ export type StateSetters<TTableDef extends State.SQLite.ClientDocumentTableDef.TraitAny> = Dispatch<
140
+ SetStateAction<TTableDef['Value']>
141
+ >
142
+
143
+ const validateTableOptions = (table: State.SQLite.TableDef<any, any>) => {
144
+ if (State.SQLite.tableIsClientDocumentTable(table) === false) {
145
+ return shouldNeverHappen(
146
+ `useClientDocument called on table "${table.sqliteDef.name}" which is not a client document table`,
147
+ )
148
+ }
149
+ }
150
+
151
+ const removeUndefinedValues = (value: any) => {
152
+ if (typeof value === 'object' && value !== null) {
153
+ return Object.fromEntries(Object.entries(value).filter(([_, v]) => v !== undefined))
154
+ }
155
+
156
+ return value
157
+ }
@@ -1,82 +1,193 @@
1
- import { queryDb } from '@livestore/livestore'
1
+ import '@livestore/utils-dev/node-vitest-polyfill'
2
+
3
+ import { queryDb, signal } from '@livestore/livestore'
4
+ import * as LiveStore from '@livestore/livestore'
5
+ import { RG } from '@livestore/livestore/internal/testing-utils'
2
6
  import { Effect, Schema } from '@livestore/utils/effect'
3
- import { renderHook } from '@testing-library/react'
7
+ import { Vitest } from '@livestore/utils-dev/node-vitest'
8
+ import * as ReactTesting from '@testing-library/react'
4
9
  import React from 'react'
5
- import { describe, expect, it } from 'vitest'
6
-
7
- import { makeTodoMvcReact, tables, todos } from './__tests__/fixture.js'
8
- import * as LiveStoreReact from './mod.js'
9
-
10
- describe('useQuery', () => {
11
- it('simple', () =>
12
- Effect.gen(function* () {
13
- const { wrapper, store, makeRenderCount } = yield* makeTodoMvcReact()
14
-
15
- const renderCount = makeRenderCount()
16
-
17
- const allTodos$ = queryDb({ query: `select * from todos`, schema: Schema.Array(tables.todos.schema) })
18
-
19
- const { result } = renderHook(
20
- () => {
21
- renderCount.inc()
22
-
23
- return LiveStoreReact.useQuery(allTodos$)
24
- },
25
- { wrapper },
26
- )
27
-
28
- expect(result.current.length).toBe(0)
29
- expect(renderCount.val).toBe(1)
30
-
31
- React.act(() => store.mutate(todos.insert({ id: 't1', text: 'buy milk', completed: false })))
32
-
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))
37
-
38
- it('same `useQuery` hook invoked with different queries', () =>
39
- Effect.gen(function* () {
40
- const { wrapper, store, makeRenderCount } = yield* makeTodoMvcReact()
41
-
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
- )
10
+ // @ts-expect-error no types
11
+ import * as ReactWindow from 'react-window'
12
+ import { expect } from 'vitest'
13
+
14
+ import { events, makeTodoMvcReact, tables } from './__tests__/fixture.js'
15
+ import { __resetUseRcResourceCache } from './useRcResource.js'
52
16
 
53
- store.mutate(
54
- todos.insert({ id: 't1', text: 'buy milk', completed: false }),
55
- todos.insert({ id: 't2', text: 'buy eggs', completed: false }),
56
- )
17
+ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
18
+ 'useQuery (strictMode=%s)',
19
+ ({ strictMode }) => {
20
+ Vitest.afterEach(() => {
21
+ RG.__resetIds()
22
+ __resetUseRcResourceCache()
23
+ })
24
+
25
+ Vitest.scopedLive('simple', () =>
26
+ Effect.gen(function* () {
27
+ const { wrapper, store, renderCount } = yield* makeTodoMvcReact({ strictMode })
57
28
 
58
- const { result, rerender } = renderHook(
59
- (todoId: string) => {
60
- renderCount.inc()
29
+ const allTodos$ = queryDb({ query: `select * from todos`, schema: Schema.Array(tables.todos.rowSchema) })
30
+
31
+ const { result } = ReactTesting.renderHook(
32
+ () => {
33
+ renderCount.inc()
61
34
 
62
- const query$ = React.useMemo(() => (todoId === 't1' ? todo1$ : todo2$), [todoId])
35
+ return store.useQuery(allTodos$)
36
+ },
37
+ { wrapper },
38
+ )
63
39
 
64
- return LiveStoreReact.useQuery(query$)[0]!.text
65
- },
66
- { wrapper, initialProps: 't1' },
67
- )
40
+ expect(result.current.length).toBe(0)
41
+ expect(renderCount.val).toBe(1)
42
+ expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
43
+
44
+ ReactTesting.act(() => store.commit(events.todoCreated({ id: 't1', text: 'buy milk', completed: false })))
45
+
46
+ expect(result.current.length).toBe(1)
47
+ expect(result.current[0]!.text).toBe('buy milk')
48
+ expect(renderCount.val).toBe(2)
49
+ expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
50
+ }),
51
+ )
52
+
53
+ Vitest.scopedLive('same `useQuery` hook invoked with different queries', () =>
54
+ Effect.gen(function* () {
55
+ const { wrapper, store, renderCount } = yield* makeTodoMvcReact({ strictMode })
56
+
57
+ const todo1$ = queryDb(
58
+ { query: `select * from todos where id = 't1'`, schema: Schema.Array(tables.todos.rowSchema) },
59
+ { label: 'libraryTracksView1' },
60
+ )
61
+ const todo2$ = queryDb(
62
+ { query: `select * from todos where id = 't2'`, schema: Schema.Array(tables.todos.rowSchema) },
63
+ { label: 'libraryTracksView2' },
64
+ )
65
+
66
+ store.commit(
67
+ events.todoCreated({ id: 't1', text: 'buy milk', completed: false }),
68
+ events.todoCreated({ id: 't2', text: 'buy eggs', completed: false }),
69
+ )
70
+
71
+ const { result, rerender } = ReactTesting.renderHook(
72
+ (todoId: string) => {
73
+ renderCount.inc()
74
+
75
+ const query$ = React.useMemo(() => (todoId === 't1' ? todo1$ : todo2$), [todoId])
76
+
77
+ return store.useQuery(query$)[0]!.text
78
+ },
79
+ { wrapper, initialProps: 't1' },
80
+ )
81
+
82
+ expect(result.current).toBe('buy milk')
83
+ expect(renderCount.val).toBe(1)
84
+ expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot('1: after first render')
85
+
86
+ ReactTesting.act(() => store.commit(events.todoUpdated({ id: 't1', text: 'buy soy milk' })))
87
+
88
+ expect(result.current).toBe('buy soy milk')
89
+ expect(renderCount.val).toBe(2)
90
+ expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot('2: after first commit')
91
+
92
+ rerender('t2')
93
+
94
+ expect(result.current).toBe('buy eggs')
95
+ expect(renderCount.val).toBe(3)
96
+ expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot('3: after forced rerender')
97
+ }),
98
+ )
99
+
100
+ Vitest.scopedLive('filtered dependency query', () =>
101
+ Effect.gen(function* () {
102
+ const { wrapper, store, renderCount } = yield* makeTodoMvcReact({ strictMode })
103
+
104
+ const filter$ = signal('t1', { label: 'id-filter' })
105
+
106
+ const todo$ = queryDb((get) => tables.todos.where('id', get(filter$)), { label: 'todo' })
107
+
108
+ store.commit(
109
+ events.todoCreated({ id: 't1', text: 'buy milk', completed: false }),
110
+ events.todoCreated({ id: 't2', text: 'buy eggs', completed: false }),
111
+ )
112
+
113
+ const { result } = ReactTesting.renderHook(
114
+ () => {
115
+ renderCount.inc()
116
+
117
+ return store.useQuery(todo$)[0]!.text
118
+ },
119
+ { wrapper },
120
+ )
121
+
122
+ expect(result.current).toBe('buy milk')
123
+ expect(renderCount.val).toBe(1)
124
+ expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
125
+
126
+ ReactTesting.act(() => store.commit(events.todoUpdated({ id: 't1', text: 'buy soy milk' })))
127
+
128
+ expect(result.current).toBe('buy soy milk')
129
+ expect(renderCount.val).toBe(2)
130
+ expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
131
+
132
+ ReactTesting.act(() => store.setSignal(filter$, 't2'))
133
+
134
+ expect(result.current).toBe('buy eggs')
135
+ expect(renderCount.val).toBe(3)
136
+ expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
137
+ }),
138
+ )
139
+
140
+ // NOTE this test covers some special react lifecyle paths which I couldn't easily reproduce without react-window
141
+ // it basically causes a "query swap" in the `useMemo` and both a `useEffect` cleanup call.
142
+ // To handle this properly we introduced the `_tag: 'destroyed'` state in the `spanAlreadyStartedCache`.
143
+ Vitest.scopedLive('should work for a list with react-window', () =>
144
+ Effect.gen(function* () {
145
+ const { wrapper, store } = yield* makeTodoMvcReact({ strictMode })
146
+
147
+ const ListWrapper: React.FC<{ numItems: number }> = ({ numItems }) => {
148
+ return (
149
+ <ReactWindow.FixedSizeList
150
+ height={100}
151
+ width={100}
152
+ itemSize={10}
153
+ itemCount={numItems}
154
+ itemData={Array.from({ length: numItems }, (_, i) => i).reverse()}
155
+ >
156
+ {ListItem}
157
+ </ReactWindow.FixedSizeList>
158
+ )
159
+ }
160
+
161
+ const ListItem: React.FC<{ data: ReadonlyArray<number>; index: number }> = ({ data: ids, index }) => {
162
+ const id = ids[index]!
163
+ const res = store.useQuery(LiveStore.computed(() => id, { label: `ListItem.${id}`, deps: id }))
164
+ // biome-ignore lint/a11y/useSemanticElements: <explanation>
165
+ return <div role="listitem">{res}</div>
166
+ }
68
167
 
69
- expect(result.current).toBe('buy milk')
70
- expect(renderCount.val).toBe(1)
168
+ const renderResult = ReactTesting.render(<ListWrapper numItems={1} />, { wrapper })
71
169
 
72
- React.act(() => store.mutate(todos.update({ where: { id: 't1' }, values: { text: 'buy soy milk' } })))
170
+ expect(renderResult.container.textContent).toBe('0')
73
171
 
74
- expect(result.current).toBe('buy soy milk')
75
- expect(renderCount.val).toBe(2)
172
+ renderResult.rerender(<ListWrapper numItems={2} />)
76
173
 
77
- rerender('t2')
174
+ expect(renderResult.container.textContent).toBe('10')
175
+ }),
176
+ )
177
+
178
+ Vitest.scopedLive('should work with signal', () =>
179
+ Effect.gen(function* () {
180
+ const { wrapper, store } = yield* makeTodoMvcReact({ strictMode })
181
+ const num$ = signal(0)
182
+
183
+ const { result } = ReactTesting.renderHook(() => store.useQuery(num$), { wrapper })
78
184
 
79
- expect(result.current).toBe('buy eggs')
80
- expect(renderCount.val).toBe(3)
81
- }).pipe(Effect.scoped, Effect.tapCauseLogPretty, Effect.runPromise))
82
- })
185
+ expect(result.current).toBe(0)
186
+
187
+ ReactTesting.act(() => store.setSignal(num$, 1))
188
+
189
+ expect(result.current).toBe(1)
190
+ }),
191
+ )
192
+ },
193
+ )
package/src/useQuery.ts CHANGED
@@ -1,38 +1,52 @@
1
- import type { GetResult, LiveQueryAny } from '@livestore/livestore'
1
+ /* eslint-disable react-hooks/rules-of-hooks */
2
+ import type { LiveQuery, LiveQueryDef, Store } from '@livestore/livestore'
2
3
  import { extractStackInfoFromStackTrace, stackInfoToString } from '@livestore/livestore'
3
- import { deepEqual, indent } from '@livestore/utils'
4
+ import type { LiveQueries } from '@livestore/livestore/internal'
5
+ import { deepEqual, indent, shouldNeverHappen } from '@livestore/utils'
4
6
  import * as otel from '@opentelemetry/api'
5
7
  import React from 'react'
6
8
 
7
- import { useStore } from './LiveStoreContext.js'
9
+ import { LiveStoreContext } from './LiveStoreContext.js'
10
+ import { useRcResource } from './useRcResource.js'
8
11
  import { originalStackLimit } from './utils/stack-info.js'
9
12
  import { useStateRefWithReactiveInput } from './utils/useStateRefWithReactiveInput.js'
10
13
 
11
14
  /**
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.
15
+ * Returns the result of a query and subscribes to future updates.
16
+ *
17
+ * Example:
18
+ * ```tsx
19
+ * const App = () => {
20
+ * const todos = useQuery(queryDb(tables.todos.query.where({ complete: true })))
21
+ * return <div>{todos.map((todo) => <div key={todo.id}>{todo.title}</div>)}</div>
22
+ * }
23
+ * ```
20
24
  */
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
25
+ export const useQuery = <TQuery extends LiveQueryDef.Any>(
26
+ queryDef: TQuery,
27
+ options?: { store?: Store },
28
+ ): LiveQueries.GetResult<TQuery> => useQueryRef(queryDef, options).valueRef.current
24
29
 
25
30
  /**
26
- *
27
31
  */
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}`)
32
+ export const useQueryRef = <TQuery extends LiveQueryDef.Any>(
33
+ queryDef: TQuery,
34
+ options?: {
35
+ store?: Store
36
+ /** Parent otel context for the query */
37
+ otelContext?: otel.Context
38
+ /** The name of the span to use for the query */
39
+ otelSpanName?: string
40
+ },
41
+ ): {
42
+ valueRef: React.RefObject<LiveQueries.GetResult<TQuery>>
43
+ queryRcRef: LiveQueries.RcRef<LiveQuery<LiveQueries.GetResult<TQuery>>>
44
+ } => {
45
+ const store =
46
+ options?.store ?? React.useContext(LiveStoreContext)?.store ?? shouldNeverHappen(`No store provided to useQuery`)
47
+
48
+ // It's important to use all "aspects" of a store instance here, otherwise we get unexpected cache mappings
49
+ const rcRefKey = `${store.storeId}_${store.clientId}_${store.sessionId}_${queryDef.hash}`
36
50
 
37
51
  const stackInfo = React.useMemo(() => {
38
52
  Error.stackTraceLimit = 10
@@ -42,33 +56,50 @@ export const useQueryRef = <TQuery extends LiveQueryAny>(
42
56
  return extractStackInfoFromStackTrace(stack)
43
57
  }, [])
44
58
 
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
59
+ const { queryRcRef, span, otelContext } = useRcResource(
60
+ rcRefKey,
61
+ () => {
62
+ const queryDefLabel = queryDef.label
49
63
 
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
- )
64
+ const span = store.otel.tracer.startSpan(
65
+ options?.otelSpanName ?? `LiveStore:useQuery:${queryDefLabel}`,
66
+ { attributes: { label: queryDefLabel, firstStackInfo: JSON.stringify(stackInfo) } },
67
+ options?.otelContext ?? store.otel.queriesSpanContext,
68
+ )
55
69
 
56
- const otelContext = otel.trace.setSpan(otel.context.active(), span)
70
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
57
71
 
58
- spanAlreadyStartedCache.set(query$, { span, otelContext })
72
+ const queryRcRef = queryDef.make(store.reactivityGraph.context!, otelContext)
59
73
 
60
- return { span, otelContext }
61
- }, [parentOtelContext, query$, stackInfo, store.otel.queriesSpanContext, store.otel.tracer])
74
+ return { queryRcRef, span, otelContext }
75
+ },
76
+ // We need to keep the queryRcRef alive a bit longer, so we have a second `useRcResource` below
77
+ // which takes care of disposing the queryRcRef
78
+ () => {},
79
+ )
80
+
81
+ // if (queryRcRef.value._tag === 'signal') {
82
+ // const queryRcRef.value.get()
83
+ // }
84
+
85
+ const query$ = queryRcRef.value as LiveQuery<LiveQueries.GetResult<TQuery>>
86
+
87
+ React.useDebugValue(`LiveStore:useQuery:${query$.id}:${query$.label}`)
88
+ // console.debug(`LiveStore:useQuery:${query$.id}:${query$.label}`)
62
89
 
63
90
  const initialResult = React.useMemo(() => {
64
91
  try {
65
- return query$.run(otelContext, {
66
- _tag: 'react',
67
- api: 'useQuery',
68
- label: query$.label,
69
- stackInfo,
92
+ return query$.run({
93
+ otelContext,
94
+ debugRefreshReason: {
95
+ _tag: 'react',
96
+ api: 'useQuery',
97
+ label: `useQuery:initial-run:${query$.label}`,
98
+ stackInfo,
99
+ },
70
100
  })
71
101
  } catch (cause: any) {
102
+ console.error('[@livestore/react:useQuery] Error running query', cause)
72
103
  throw new Error(
73
104
  `\
74
105
  [@livestore/react:useQuery] Error running query: ${cause.name}
@@ -87,27 +118,22 @@ Stack trace:
87
118
  }, [otelContext, query$, stackInfo])
88
119
 
89
120
  // We know the query has a result by the time we use it; so we can synchronously populate a default state
90
- const [valueRef, setValue] = useStateRefWithReactiveInput<GetResult<TQuery>>(initialResult)
121
+ const [valueRef, setValue] = useStateRefWithReactiveInput<LiveQueries.GetResult<TQuery>>(initialResult)
91
122
 
92
- React.useEffect(
93
- () => () => {
94
- spanAlreadyStartedCache.delete(query$)
95
- span.end()
96
- },
97
- [query$, span],
98
- )
123
+ // TODO we probably need to change the order of `useEffect` calls, so we destroy the query at the end
124
+ // before calling the LS `onEffect` on it
99
125
 
100
126
  // Subscribe to future updates for this query
101
127
  React.useEffect(() => {
128
+ // TODO double check whether we still need `activeSubscriptions`
102
129
  query$.activeSubscriptions.add(stackInfo)
103
130
 
104
131
  // Dynamic queries only set their actual label after they've been run the first time,
105
132
  // so we're also updating the span name here.
106
- span.updateName(`LiveStore:useQuery:${query$.label}`)
133
+ span.updateName(options?.otelSpanName ?? `LiveStore:useQuery:${query$.label}`)
107
134
 
108
- return store.subscribe(
109
- query$,
110
- (newValue) => {
135
+ return store.subscribe(query$, {
136
+ onUpdate: (newValue) => {
111
137
  // NOTE: we return a reference to the result object within LiveStore;
112
138
  // this implies that app code must not mutate the results, or else
113
139
  // there may be weird reactivity bugs.
@@ -115,12 +141,23 @@ Stack trace:
115
141
  setValue(newValue)
116
142
  }
117
143
  },
118
- () => {
144
+ onUnsubsubscribe: () => {
119
145
  query$.activeSubscriptions.delete(stackInfo)
120
146
  },
121
- { label: query$.label, otelContext },
122
- )
123
- }, [stackInfo, query$, setValue, store, valueRef, otelContext, span])
147
+ label: query$.label,
148
+ otelContext,
149
+ })
150
+ }, [stackInfo, query$, setValue, store, valueRef, otelContext, span, options?.otelSpanName])
151
+
152
+ useRcResource(
153
+ rcRefKey,
154
+ () => ({ queryRcRef, span }),
155
+ ({ queryRcRef, span }) => {
156
+ // console.debug('deref', queryRcRef.value.id, queryRcRef.value.label)
157
+ queryRcRef.deref()
158
+ span.end()
159
+ },
160
+ )
124
161
 
125
- return valueRef
162
+ return { valueRef, queryRcRef }
126
163
  }