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

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 (99) 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 +31 -13
  7. package/dist/LiveStoreProvider.d.ts.map +1 -1
  8. package/dist/LiveStoreProvider.js +85 -52
  9. package/dist/LiveStoreProvider.js.map +1 -1
  10. package/dist/LiveStoreProvider.test.js +98 -26
  11. package/dist/LiveStoreProvider.test.js.map +1 -1
  12. package/dist/__tests__/fixture.d.ts +121 -555
  13. package/dist/__tests__/fixture.d.ts.map +1 -1
  14. package/dist/__tests__/fixture.js +69 -28
  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 +7 -4
  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 +180 -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 +66 -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 +76 -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 +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/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 +1 -1
  53. package/dist/utils/useStateRefWithReactiveInput.d.ts.map +1 -1
  54. package/dist/utils/useStateRefWithReactiveInput.js.map +1 -1
  55. package/package.json +26 -19
  56. package/src/LiveStoreContext.ts +11 -16
  57. package/src/LiveStoreProvider.test.tsx +176 -37
  58. package/src/LiveStoreProvider.tsx +159 -82
  59. package/src/__snapshots__/useClientDocument.test.tsx.snap +613 -0
  60. package/src/__snapshots__/useQuery.test.tsx.snap +2011 -0
  61. package/src/__tests__/fixture.tsx +75 -48
  62. package/src/experimental/components/LiveList.tsx +10 -7
  63. package/src/mod.ts +5 -6
  64. package/src/useClientDocument.test.tsx +306 -0
  65. package/src/useClientDocument.ts +157 -0
  66. package/src/useQuery.test.tsx +171 -76
  67. package/src/useQuery.ts +91 -58
  68. package/src/useRcResource.test.tsx +167 -0
  69. package/src/useRcResource.ts +180 -0
  70. package/src/useStore.ts +36 -0
  71. package/src/utils/useStateRefWithReactiveInput.ts +1 -1
  72. package/dist/useAtom.d.ts +0 -5
  73. package/dist/useAtom.d.ts.map +0 -1
  74. package/dist/useAtom.js +0 -38
  75. package/dist/useAtom.js.map +0 -1
  76. package/dist/useRow.d.ts +0 -50
  77. package/dist/useRow.d.ts.map +0 -1
  78. package/dist/useRow.js +0 -93
  79. package/dist/useRow.js.map +0 -1
  80. package/dist/useRow.test.d.ts +0 -2
  81. package/dist/useRow.test.d.ts.map +0 -1
  82. package/dist/useRow.test.js +0 -206
  83. package/dist/useRow.test.js.map +0 -1
  84. package/dist/useScopedQuery.d.ts +0 -33
  85. package/dist/useScopedQuery.d.ts.map +0 -1
  86. package/dist/useScopedQuery.js +0 -86
  87. package/dist/useScopedQuery.js.map +0 -1
  88. package/dist/useScopedQuery.test.d.ts +0 -2
  89. package/dist/useScopedQuery.test.d.ts.map +0 -1
  90. package/dist/useScopedQuery.test.js +0 -60
  91. package/dist/useScopedQuery.test.js.map +0 -1
  92. package/src/__snapshots__/useRow.test.tsx.snap +0 -367
  93. package/src/useAtom.ts +0 -52
  94. package/src/useRow.test.tsx +0 -343
  95. package/src/useRow.ts +0 -188
  96. package/src/useScopedQuery.test.tsx +0 -96
  97. package/src/useScopedQuery.ts +0 -142
  98. package/tsconfig.json +0 -20
  99. 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,177 @@
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
- )
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)
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'
71
16
 
72
- React.act(() => store.mutate(todos.update({ where: { id: 't1' }, values: { text: 'buy soy milk' } })))
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 })
73
28
 
74
- expect(result.current).toBe('buy soy milk')
75
- expect(renderCount.val).toBe(2)
29
+ const allTodos$ = queryDb({ query: `select * from todos`, schema: Schema.Array(tables.todos.rowSchema) })
30
+
31
+ const { result } = ReactTesting.renderHook(
32
+ () => {
33
+ renderCount.inc()
76
34
 
77
- rerender('t2')
35
+ return store.useQuery(allTodos$)
36
+ },
37
+ { wrapper },
38
+ )
78
39
 
79
- expect(result.current).toBe('buy eggs')
80
- expect(renderCount.val).toBe(3)
81
- }).pipe(Effect.scoped, Effect.tapCauseLogPretty, Effect.runPromise))
82
- })
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
+ return <div role="listitem">{res}</div>
165
+ }
166
+
167
+ const renderResult = ReactTesting.render(<ListWrapper numItems={1} />, { wrapper })
168
+
169
+ expect(renderResult.container.textContent).toBe('0')
170
+
171
+ renderResult.rerender(<ListWrapper numItems={2} />)
172
+
173
+ expect(renderResult.container.textContent).toBe('10')
174
+ }),
175
+ )
176
+ },
177
+ )
package/src/useQuery.ts CHANGED
@@ -1,38 +1,54 @@
1
- import type { GetResult, LiveQueryAny } from '@livestore/livestore'
1
+ import type { LiveQuery, LiveQueryDef, Store } from '@livestore/livestore'
2
2
  import { extractStackInfoFromStackTrace, stackInfoToString } from '@livestore/livestore'
3
- import { deepEqual, indent } from '@livestore/utils'
3
+ import type { LiveQueries } from '@livestore/livestore/internal'
4
+ import { deepEqual, indent, shouldNeverHappen } from '@livestore/utils'
4
5
  import * as otel from '@opentelemetry/api'
5
6
  import React from 'react'
6
7
 
7
- import { useStore } from './LiveStoreContext.js'
8
+ import { LiveStoreContext } from './LiveStoreContext.js'
9
+ import { useRcResource } from './useRcResource.js'
8
10
  import { originalStackLimit } from './utils/stack-info.js'
9
11
  import { useStateRefWithReactiveInput } from './utils/useStateRefWithReactiveInput.js'
10
12
 
11
13
  /**
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.
14
+ * Returns the result of a query and subscribes to future updates.
15
+ *
16
+ * Example:
17
+ * ```tsx
18
+ * const App = () => {
19
+ * const todos = useQuery(queryDb(tables.todos.query.where({ complete: true })))
20
+ * return <div>{todos.map((todo) => <div key={todo.id}>{todo.title}</div>)}</div>
21
+ * }
22
+ * ```
20
23
  */
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
24
+ export const useQuery = <TQuery extends LiveQueryDef.Any>(
25
+ queryDef: TQuery,
26
+ options?: { store?: Store },
27
+ ): LiveQueries.GetResult<TQuery> => useQueryRef(queryDef, options).valueRef.current
24
28
 
25
29
  /**
26
- *
27
30
  */
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}`)
31
+ export const useQueryRef = <TQuery extends LiveQueryDef.Any>(
32
+ queryDef: TQuery,
33
+ options?: {
34
+ store?: Store
35
+ /** Parent otel context for the query */
36
+ otelContext?: otel.Context
37
+ /** The name of the span to use for the query */
38
+ otelSpanName?: string
39
+ },
40
+ ): {
41
+ valueRef: React.RefObject<LiveQueries.GetResult<TQuery>>
42
+ queryRcRef: LiveQueries.RcRef<LiveQuery<LiveQueries.GetResult<TQuery>>>
43
+ } => {
44
+ const store =
45
+ options?.store ??
46
+ // eslint-disable-next-line react-hooks/rules-of-hooks
47
+ React.useContext(LiveStoreContext)?.store ??
48
+ shouldNeverHappen(`No store provided to useQuery`)
49
+
50
+ // 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}`
36
52
 
37
53
  const stackInfo = React.useMemo(() => {
38
54
  Error.stackTraceLimit = 10
@@ -42,31 +58,42 @@ export const useQueryRef = <TQuery extends LiveQueryAny>(
42
58
  return extractStackInfoFromStackTrace(stack)
43
59
  }, [])
44
60
 
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
61
+ const { queryRcRef, span, otelContext } = useRcResource(
62
+ rcRefKey,
63
+ () => {
64
+ const queryDefLabel = queryDef.label
49
65
 
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
- )
66
+ const span = store.otel.tracer.startSpan(
67
+ options?.otelSpanName ?? `LiveStore:useQuery:${queryDefLabel}`,
68
+ { attributes: { label: queryDefLabel, firstStackInfo: JSON.stringify(stackInfo) } },
69
+ options?.otelContext ?? store.otel.queriesSpanContext,
70
+ )
55
71
 
56
- const otelContext = otel.trace.setSpan(otel.context.active(), span)
72
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
57
73
 
58
- spanAlreadyStartedCache.set(query$, { span, otelContext })
74
+ const queryRcRef = queryDef.make(store.reactivityGraph.context!, otelContext)
59
75
 
60
- return { span, otelContext }
61
- }, [parentOtelContext, query$, stackInfo, store.otel.queriesSpanContext, store.otel.tracer])
76
+ return { queryRcRef, span, otelContext }
77
+ },
78
+ // We need to keep the queryRcRef alive a bit longer, so we have a second `useRcResource` below
79
+ // which takes care of disposing the queryRcRef
80
+ () => {},
81
+ )
82
+ const query$ = queryRcRef.value as LiveQuery<LiveQueries.GetResult<TQuery>>
83
+
84
+ React.useDebugValue(`LiveStore:useQuery:${query$.id}:${query$.label}`)
85
+ // console.debug(`LiveStore:useQuery:${query$.id}:${query$.label}`)
62
86
 
63
87
  const initialResult = React.useMemo(() => {
64
88
  try {
65
- return query$.run(otelContext, {
66
- _tag: 'react',
67
- api: 'useQuery',
68
- label: query$.label,
69
- stackInfo,
89
+ return query$.run({
90
+ otelContext,
91
+ debugRefreshReason: {
92
+ _tag: 'react',
93
+ api: 'useQuery',
94
+ label: `useQuery:initial-run:${query$.label}`,
95
+ stackInfo,
96
+ },
70
97
  })
71
98
  } catch (cause: any) {
72
99
  throw new Error(
@@ -87,27 +114,22 @@ Stack trace:
87
114
  }, [otelContext, query$, stackInfo])
88
115
 
89
116
  // 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)
117
+ const [valueRef, setValue] = useStateRefWithReactiveInput<LiveQueries.GetResult<TQuery>>(initialResult)
91
118
 
92
- React.useEffect(
93
- () => () => {
94
- spanAlreadyStartedCache.delete(query$)
95
- span.end()
96
- },
97
- [query$, span],
98
- )
119
+ // TODO we probably need to change the order of `useEffect` calls, so we destroy the query at the end
120
+ // before calling the LS `onEffect` on it
99
121
 
100
122
  // Subscribe to future updates for this query
101
123
  React.useEffect(() => {
124
+ // TODO double check whether we still need `activeSubscriptions`
102
125
  query$.activeSubscriptions.add(stackInfo)
103
126
 
104
127
  // Dynamic queries only set their actual label after they've been run the first time,
105
128
  // so we're also updating the span name here.
106
- span.updateName(`LiveStore:useQuery:${query$.label}`)
129
+ span.updateName(options?.otelSpanName ?? `LiveStore:useQuery:${query$.label}`)
107
130
 
108
- return store.subscribe(
109
- query$,
110
- (newValue) => {
131
+ return store.subscribe(query$, {
132
+ onUpdate: (newValue) => {
111
133
  // NOTE: we return a reference to the result object within LiveStore;
112
134
  // this implies that app code must not mutate the results, or else
113
135
  // there may be weird reactivity bugs.
@@ -115,12 +137,23 @@ Stack trace:
115
137
  setValue(newValue)
116
138
  }
117
139
  },
118
- () => {
140
+ onUnsubsubscribe: () => {
119
141
  query$.activeSubscriptions.delete(stackInfo)
120
142
  },
121
- { label: query$.label, otelContext },
122
- )
123
- }, [stackInfo, query$, setValue, store, valueRef, otelContext, span])
143
+ label: query$.label,
144
+ otelContext,
145
+ })
146
+ }, [stackInfo, query$, setValue, store, valueRef, otelContext, span, options?.otelSpanName])
147
+
148
+ useRcResource(
149
+ rcRefKey,
150
+ () => ({ queryRcRef, span }),
151
+ ({ queryRcRef, span }) => {
152
+ // console.debug('deref', queryRcRef.value.id, queryRcRef.value.label)
153
+ queryRcRef.deref()
154
+ span.end()
155
+ },
156
+ )
124
157
 
125
- return valueRef
158
+ return { valueRef, queryRcRef }
126
159
  }