@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/LiveStoreContext.d.ts +10 -4
- package/dist/LiveStoreContext.d.ts.map +1 -1
- package/dist/LiveStoreContext.js +1 -11
- package/dist/LiveStoreContext.js.map +1 -1
- package/dist/LiveStoreProvider.d.ts +31 -13
- package/dist/LiveStoreProvider.d.ts.map +1 -1
- package/dist/LiveStoreProvider.js +85 -52
- package/dist/LiveStoreProvider.js.map +1 -1
- package/dist/LiveStoreProvider.test.js +98 -26
- package/dist/LiveStoreProvider.test.js.map +1 -1
- package/dist/__tests__/fixture.d.ts +121 -555
- package/dist/__tests__/fixture.d.ts.map +1 -1
- package/dist/__tests__/fixture.js +69 -28
- package/dist/__tests__/fixture.js.map +1 -1
- package/dist/experimental/components/LiveList.d.ts +2 -2
- package/dist/experimental/components/LiveList.d.ts.map +1 -1
- package/dist/experimental/components/LiveList.js +7 -4
- package/dist/experimental/components/LiveList.js.map +1 -1
- package/dist/mod.d.ts +4 -5
- package/dist/mod.d.ts.map +1 -1
- package/dist/mod.js +4 -5
- package/dist/mod.js.map +1 -1
- package/dist/useClientDocument.d.ts +61 -0
- package/dist/useClientDocument.d.ts.map +1 -0
- package/dist/useClientDocument.js +79 -0
- package/dist/useClientDocument.js.map +1 -0
- package/dist/useClientDocument.test.d.ts +2 -0
- package/dist/useClientDocument.test.d.ts.map +1 -0
- package/dist/useClientDocument.test.js +180 -0
- package/dist/useClientDocument.test.js.map +1 -0
- package/dist/useQuery.d.ts +25 -3
- package/dist/useQuery.d.ts.map +1 -1
- package/dist/useQuery.js +66 -47
- package/dist/useQuery.js.map +1 -1
- package/dist/useQuery.test.d.ts +1 -1
- package/dist/useQuery.test.d.ts.map +1 -1
- package/dist/useQuery.test.js +76 -24
- package/dist/useQuery.test.js.map +1 -1
- package/dist/useRcResource.d.ts +76 -0
- package/dist/useRcResource.d.ts.map +1 -0
- package/dist/useRcResource.js +150 -0
- package/dist/useRcResource.js.map +1 -0
- package/dist/useRcResource.test.d.ts +2 -0
- package/dist/useRcResource.test.d.ts.map +1 -0
- package/dist/useRcResource.test.js +122 -0
- package/dist/useRcResource.test.js.map +1 -0
- package/dist/useStore.d.ts +9 -0
- package/dist/useStore.d.ts.map +1 -0
- package/dist/useStore.js +28 -0
- package/dist/useStore.js.map +1 -0
- package/dist/utils/useStateRefWithReactiveInput.d.ts +1 -1
- package/dist/utils/useStateRefWithReactiveInput.d.ts.map +1 -1
- package/dist/utils/useStateRefWithReactiveInput.js.map +1 -1
- package/package.json +26 -19
- package/src/LiveStoreContext.ts +11 -16
- package/src/LiveStoreProvider.test.tsx +176 -37
- package/src/LiveStoreProvider.tsx +159 -82
- package/src/__snapshots__/useClientDocument.test.tsx.snap +613 -0
- package/src/__snapshots__/useQuery.test.tsx.snap +2011 -0
- package/src/__tests__/fixture.tsx +75 -48
- package/src/experimental/components/LiveList.tsx +10 -7
- package/src/mod.ts +5 -6
- package/src/useClientDocument.test.tsx +306 -0
- package/src/useClientDocument.ts +157 -0
- package/src/useQuery.test.tsx +171 -76
- package/src/useQuery.ts +91 -58
- package/src/useRcResource.test.tsx +167 -0
- package/src/useRcResource.ts +180 -0
- package/src/useStore.ts +36 -0
- package/src/utils/useStateRefWithReactiveInput.ts +1 -1
- package/dist/useAtom.d.ts +0 -5
- package/dist/useAtom.d.ts.map +0 -1
- package/dist/useAtom.js +0 -38
- package/dist/useAtom.js.map +0 -1
- package/dist/useRow.d.ts +0 -50
- package/dist/useRow.d.ts.map +0 -1
- package/dist/useRow.js +0 -93
- package/dist/useRow.js.map +0 -1
- package/dist/useRow.test.d.ts +0 -2
- package/dist/useRow.test.d.ts.map +0 -1
- package/dist/useRow.test.js +0 -206
- package/dist/useRow.test.js.map +0 -1
- package/dist/useScopedQuery.d.ts +0 -33
- package/dist/useScopedQuery.d.ts.map +0 -1
- package/dist/useScopedQuery.js +0 -86
- package/dist/useScopedQuery.js.map +0 -1
- package/dist/useScopedQuery.test.d.ts +0 -2
- package/dist/useScopedQuery.test.d.ts.map +0 -1
- package/dist/useScopedQuery.test.js +0 -60
- package/dist/useScopedQuery.test.js.map +0 -1
- package/src/__snapshots__/useRow.test.tsx.snap +0 -367
- package/src/useAtom.ts +0 -52
- package/src/useRow.test.tsx +0 -343
- package/src/useRow.ts +0 -188
- package/src/useScopedQuery.test.tsx +0 -96
- package/src/useScopedQuery.ts +0 -142
- package/tsconfig.json +0 -20
- 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
|
+
}
|
package/src/useQuery.test.tsx
CHANGED
|
@@ -1,82 +1,177 @@
|
|
|
1
|
-
import
|
|
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 {
|
|
7
|
+
import { Vitest } from '@livestore/utils-dev/node-vitest'
|
|
8
|
+
import * as ReactTesting from '@testing-library/react'
|
|
4
9
|
import React from 'react'
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
35
|
+
return store.useQuery(allTodos$)
|
|
36
|
+
},
|
|
37
|
+
{ wrapper },
|
|
38
|
+
)
|
|
78
39
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 {
|
|
1
|
+
import type { LiveQuery, LiveQueryDef, Store } from '@livestore/livestore'
|
|
2
2
|
import { extractStackInfoFromStackTrace, stackInfoToString } from '@livestore/livestore'
|
|
3
|
-
import {
|
|
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 {
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
61
|
+
const { queryRcRef, span, otelContext } = useRcResource(
|
|
62
|
+
rcRefKey,
|
|
63
|
+
() => {
|
|
64
|
+
const queryDefLabel = queryDef.label
|
|
49
65
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
72
|
+
const otelContext = otel.trace.setSpan(otel.context.active(), span)
|
|
57
73
|
|
|
58
|
-
|
|
74
|
+
const queryRcRef = queryDef.make(store.reactivityGraph.context!, otelContext)
|
|
59
75
|
|
|
60
|
-
|
|
61
|
-
|
|
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(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
}
|