@livestore/react 0.4.0-dev.22 → 0.4.0-dev.23

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 (65) hide show
  1. package/README.md +1 -1
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/StoreRegistryContext.d.ts +1 -1
  4. package/dist/StoreRegistryContext.d.ts.map +1 -1
  5. package/dist/StoreRegistryContext.js +2 -2
  6. package/dist/StoreRegistryContext.js.map +1 -1
  7. package/dist/__tests__/fixture.d.ts +8 -280
  8. package/dist/__tests__/fixture.d.ts.map +1 -1
  9. package/dist/__tests__/fixture.js +8 -78
  10. package/dist/__tests__/fixture.js.map +1 -1
  11. package/dist/experimental/components/LiveList.d.ts.map +1 -1
  12. package/dist/experimental/components/LiveList.js +5 -4
  13. package/dist/experimental/components/LiveList.js.map +1 -1
  14. package/dist/mod.d.ts +4 -2
  15. package/dist/mod.d.ts.map +1 -1
  16. package/dist/mod.js +3 -2
  17. package/dist/mod.js.map +1 -1
  18. package/dist/useClientDocument.d.ts +1 -26
  19. package/dist/useClientDocument.d.ts.map +1 -1
  20. package/dist/useClientDocument.js +2 -13
  21. package/dist/useClientDocument.js.map +1 -1
  22. package/dist/useClientDocument.test.js +12 -4
  23. package/dist/useClientDocument.test.js.map +1 -1
  24. package/dist/useQuery.d.ts +3 -4
  25. package/dist/useQuery.d.ts.map +1 -1
  26. package/dist/useQuery.js +10 -80
  27. package/dist/useQuery.js.map +1 -1
  28. package/dist/useQuery.test.js +7 -8
  29. package/dist/useQuery.test.js.map +1 -1
  30. package/dist/useRcResource.d.ts.map +1 -1
  31. package/dist/useRcResource.js +9 -5
  32. package/dist/useRcResource.js.map +1 -1
  33. package/dist/useRcResource.test.js +1 -1
  34. package/dist/useRcResource.test.js.map +1 -1
  35. package/dist/useStore.d.ts +12 -1
  36. package/dist/useStore.d.ts.map +1 -1
  37. package/dist/useStore.js +21 -13
  38. package/dist/useStore.js.map +1 -1
  39. package/dist/useStore.test.js +53 -8
  40. package/dist/useStore.test.js.map +1 -1
  41. package/dist/useSyncStatus.d.ts +22 -0
  42. package/dist/useSyncStatus.d.ts.map +1 -0
  43. package/dist/useSyncStatus.js +28 -0
  44. package/dist/useSyncStatus.js.map +1 -0
  45. package/package.json +68 -25
  46. package/src/StoreRegistryContext.tsx +4 -3
  47. package/src/__snapshots__/useClientDocument.test.tsx.snap +112 -78
  48. package/src/__tests__/fixture.tsx +22 -105
  49. package/src/experimental/components/LiveList.tsx +9 -5
  50. package/src/mod.ts +4 -9
  51. package/src/useClientDocument.test.tsx +16 -6
  52. package/src/useClientDocument.ts +6 -56
  53. package/src/useQuery.test.tsx +8 -8
  54. package/src/useQuery.ts +28 -113
  55. package/src/useRcResource.test.tsx +1 -1
  56. package/src/useRcResource.ts +10 -5
  57. package/src/useStore.test.tsx +85 -9
  58. package/src/useStore.ts +30 -17
  59. package/src/useSyncStatus.ts +34 -0
  60. package/dist/utils/stack-info.d.ts +0 -4
  61. package/dist/utils/stack-info.d.ts.map +0 -1
  62. package/dist/utils/stack-info.js +0 -10
  63. package/dist/utils/stack-info.js.map +0 -1
  64. package/src/ambient.d.ts +0 -1
  65. package/src/utils/stack-info.ts +0 -13
@@ -1,110 +1,32 @@
1
- import { makeInMemoryAdapter } from '@livestore/adapter-web'
2
- import { provideOtel, type UnknownError } from '@livestore/common'
3
- import { Events, makeSchema, State } from '@livestore/common/schema'
4
- import type { LiveStoreSchema, SqliteDsl, Store } from '@livestore/livestore'
5
- import { createStore } from '@livestore/livestore'
6
- import { omitUndefineds } from '@livestore/utils'
7
- import { Effect, Schema, type Scope } from '@livestore/utils/effect'
8
- import type * as otel from '@opentelemetry/api'
1
+ import type { UnknownError } from '@livestore/common'
2
+ import {
3
+ type AppState,
4
+ type CreateTodoMvcStoreOptions,
5
+ createTodoMvcStore,
6
+ events,
7
+ type Filter,
8
+ schema,
9
+ type Todo,
10
+ tables,
11
+ } from '@livestore/framework-toolkit/testing'
12
+ import type { Store } from '@livestore/livestore'
13
+ import { Effect, type Scope } from '@livestore/utils/effect'
9
14
  import React from 'react'
10
15
 
11
16
  import * as LiveStoreReact from '../mod.ts'
12
17
 
13
- export type Todo = {
14
- id: string
15
- text: string
16
- completed: boolean
17
- }
18
-
19
- export type Filter = 'all' | 'active' | 'completed'
20
-
21
- export type AppState = {
22
- newTodoText: string
23
- filter: Filter
24
- }
25
-
26
- const todos = State.SQLite.table({
27
- name: 'todos',
28
- columns: {
29
- id: State.SQLite.text({ primaryKey: true }),
30
- text: State.SQLite.text({ default: '', nullable: false }),
31
- completed: State.SQLite.boolean({ default: false, nullable: false }),
32
- },
33
- })
34
-
35
- const app = State.SQLite.table({
36
- name: 'app',
37
- columns: {
38
- id: State.SQLite.text({ primaryKey: true, default: 'static' }),
39
- newTodoText: State.SQLite.text({ default: '', nullable: true }),
40
- filter: State.SQLite.text({ default: 'all', nullable: false }),
41
- },
42
- })
43
-
44
- const userInfo = State.SQLite.clientDocument({
45
- name: 'UserInfo',
46
- schema: Schema.Struct({
47
- username: Schema.String,
48
- text: Schema.String,
49
- }),
50
- default: { value: { username: '', text: '' } },
51
- })
52
-
53
- const AppRouterSchema = State.SQLite.clientDocument({
54
- name: 'AppRouter',
55
- schema: Schema.Struct({
56
- currentTaskId: Schema.String.pipe(Schema.NullOr),
57
- }),
58
- default: {
59
- value: { currentTaskId: null },
60
- id: 'singleton',
61
- },
62
- })
63
-
64
- const kv = State.SQLite.clientDocument({
65
- name: 'Kv',
66
- schema: Schema.Any,
67
- default: { value: null },
68
- })
69
-
70
- export const events = {
71
- todoCreated: Events.synced({
72
- name: 'todoCreated',
73
- schema: Schema.Struct({ id: Schema.String, text: Schema.String, completed: Schema.Boolean }),
74
- }),
75
- todoUpdated: Events.synced({
76
- name: 'todoUpdated',
77
- schema: Schema.Struct({
78
- id: Schema.String,
79
- text: Schema.String.pipe(Schema.optional),
80
- completed: Schema.Boolean.pipe(Schema.optional),
81
- }),
82
- }),
83
- AppRouterSet: AppRouterSchema.set,
84
- UserInfoSet: userInfo.set,
85
- KvSet: kv.set,
86
- }
87
-
88
- const materializers = State.SQLite.materializers(events, {
89
- todoCreated: ({ id, text, completed }) => todos.insert({ id, text, completed }),
90
- todoUpdated: ({ id, text, completed }) => todos.update({ ...omitUndefineds({ completed, text }) }).where({ id }),
91
- })
92
-
93
- export const tables = { todos, app, userInfo, AppRouterSchema, kv }
94
-
95
- const state = State.SQLite.makeState({ tables, materializers })
96
- export const schema = makeSchema({ state, events })
18
+ // Re-export shared types and schema
19
+ export { events, schema, tables }
20
+ export type { AppState, Filter, Todo }
97
21
 
98
- export type MakeTodoMvcReactOptions = {
99
- otelTracer?: otel.Tracer | undefined
100
- otelContext?: otel.Context | undefined
22
+ export type MakeTodoMvcReactOptions = CreateTodoMvcStoreOptions & {
101
23
  strictMode?: boolean | undefined
102
24
  }
103
25
 
104
26
  export const makeTodoMvcReact: (opts?: MakeTodoMvcReactOptions) => Effect.Effect<
105
27
  {
106
28
  wrapper: ({ children }: any) => React.JSX.Element
107
- store: Store<LiveStoreSchema<SqliteDsl.DbSchema, State.SQLite.EventDefRecord>, {}> & LiveStoreReact.ReactApi
29
+ store: Store<typeof schema> & LiveStoreReact.ReactApi
108
30
  renderCount: { readonly val: number; inc: () => void }
109
31
  },
110
32
  UnknownError,
@@ -116,7 +38,7 @@ export const makeTodoMvcReact: (opts?: MakeTodoMvcReactOptions) => Effect.Effect
116
38
  let val = 0
117
39
 
118
40
  const inc = () => {
119
- val += strictMode ? 0.5 : 1
41
+ val += strictMode === true ? 0.5 : 1
120
42
  }
121
43
 
122
44
  return {
@@ -127,20 +49,15 @@ export const makeTodoMvcReact: (opts?: MakeTodoMvcReactOptions) => Effect.Effect
127
49
  }
128
50
  }
129
51
 
130
- const store: Store<any> = yield* createStore({
131
- schema,
132
- storeId: 'default',
133
- adapter: makeInMemoryAdapter(),
134
- debug: { instanceId: 'test' },
135
- })
52
+ const store = yield* createTodoMvcStore(opts)
136
53
 
137
54
  const storeWithReactApi = LiveStoreReact.withReactApi(store)
138
55
 
139
- const MaybeStrictMode = strictMode ? React.StrictMode : React.Fragment
56
+ const MaybeStrictMode = strictMode === true ? React.StrictMode : React.Fragment
140
57
 
141
58
  const wrapper = ({ children }: any) => <MaybeStrictMode>{children}</MaybeStrictMode>
142
59
 
143
60
  const renderCount = makeRenderCount()
144
61
 
145
62
  return { wrapper, store: storeWithReactApi, renderCount }
146
- }).pipe(provideOtel(omitUndefineds({ parentSpanContext: opts.otelContext, otelTracer: opts.otelTracer })))
63
+ })
@@ -60,7 +60,8 @@ export const LiveList = <TItem,>({ items$, renderItem, getKey, store }: LiveList
60
60
  itemKey={key}
61
61
  item$={item$}
62
62
  store={store}
63
- opts={{ isInitialListRender: !hasMounted, index }}
63
+ index={index}
64
+ isInitialListRender={!hasMounted}
64
65
  renderItem={renderItem}
65
66
  />
66
67
  ))}
@@ -70,17 +71,20 @@ export const LiveList = <TItem,>({ items$, renderItem, getKey, store }: LiveList
70
71
 
71
72
  const ItemWrapper = <TItem,>({
72
73
  item$,
73
- opts,
74
+ index,
75
+ isInitialListRender,
74
76
  renderItem,
75
77
  store,
76
78
  }: {
77
79
  itemKey: string | number
78
80
  item$: LiveQueryDef<TItem>
79
- opts: { index: number; isInitialListRender: boolean }
81
+ index: number
82
+ isInitialListRender: boolean
80
83
  renderItem: (item: TItem, opts: { index: number; isInitialListRender: boolean }) => React.ReactNode
81
84
  store: Store<any, any>
82
85
  }) => {
83
86
  const item = useQuery(item$, { store })
87
+ const opts = React.useMemo(() => ({ index, isInitialListRender }), [index, isInitialListRender])
84
88
 
85
89
  return <>{renderItem(item, opts)}</>
86
90
  }
@@ -91,6 +95,6 @@ const ItemWrapperMemo = React.memo(
91
95
  prev.itemKey === next.itemKey &&
92
96
  prev.renderItem === next.renderItem &&
93
97
  prev.store === next.store &&
94
- prev.opts.index === next.opts.index &&
95
- prev.opts.isInitialListRender === next.opts.isInitialListRender,
98
+ prev.index === next.index &&
99
+ prev.isInitialListRender === next.isInitialListRender,
96
100
  ) as typeof ItemWrapper
package/src/mod.ts CHANGED
@@ -1,14 +1,9 @@
1
+ export type { Dispatch, SetStateAction, SetStateActionPartial, StateSetters } from '@livestore/framework-toolkit'
2
+ export { captureStackInfo } from '@livestore/framework-toolkit'
1
3
  export { StoreRegistry, storeOptions } from '@livestore/livestore'
2
4
  export { LiveList, type LiveListProps } from './experimental/components/LiveList.tsx'
3
5
  export * from './StoreRegistryContext.tsx'
4
- export {
5
- type Dispatch,
6
- type SetStateAction,
7
- type SetStateActionPartial,
8
- type StateSetters,
9
- type UseClientDocumentResult,
10
- useClientDocument,
11
- } from './useClientDocument.ts'
6
+ export { type UseClientDocumentResult, useClientDocument } from './useClientDocument.ts'
12
7
  export { useQuery, useQueryRef } from './useQuery.ts'
13
8
  export { type ReactApi, useStore, withReactApi } from './useStore.ts'
14
- export { useStackInfo } from './utils/stack-info.ts'
9
+ export { useSyncStatus } from './useSyncStatus.ts'
@@ -8,7 +8,7 @@ import { Vitest } from '@livestore/utils-dev/node-vitest'
8
8
  import * as otel from '@opentelemetry/api'
9
9
  import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'
10
10
  import * as ReactTesting from '@testing-library/react'
11
- import type React from 'react'
11
+ import * as React from 'react'
12
12
  import { beforeEach, expect, it } from 'vitest'
13
13
 
14
14
  import { events, makeTodoMvcReact, tables } from './__tests__/fixture.tsx'
@@ -120,25 +120,35 @@ Vitest.describe('useClientDocument', () => {
120
120
  renderCount.inc()
121
121
 
122
122
  const [state, setState] = store.useClientDocument(tables.AppRouterSchema, 'singleton')
123
+ const setCurrentTaskId = React.useCallback((taskId: string) => setState({ currentTaskId: taskId }), [setState])
123
124
 
124
125
  globalSetState = setState
125
126
 
126
127
  return (
127
128
  <div>
128
- <TasksList setTaskId={(taskId) => setState({ currentTaskId: taskId })} />
129
+ <TasksList setTaskId={setCurrentTaskId} />
129
130
  <div role="current-id">Current Task Id: {state.currentTaskId ?? '-'}</div>
130
- {state.currentTaskId ? <TaskDetails id={state.currentTaskId} /> : <div>Click on a task to see details</div>}
131
+ {state.currentTaskId !== null ? <TaskDetails id={state.currentTaskId} /> : <div>Click on a task to see details</div>}
131
132
  </div>
132
133
  )
133
134
  }
134
135
 
135
136
  const TasksList: React.FC<{ setTaskId: (_: string) => void }> = ({ setTaskId }) => {
136
137
  const allTodos = store.useQuery(allTodos$)
138
+ const handleTaskClick = React.useCallback(
139
+ (event: React.MouseEvent<HTMLDivElement>) => {
140
+ const taskId = event.currentTarget.dataset.taskId
141
+ if (taskId !== undefined) {
142
+ setTaskId(taskId)
143
+ }
144
+ },
145
+ [setTaskId],
146
+ )
137
147
 
138
148
  return (
139
149
  <div>
140
150
  {allTodos.map((_) => (
141
- <div key={_.id} onClick={() => setTaskId(_.id)}>
151
+ <div key={_.id} data-task-id={_.id} onClick={handleTaskClick}>
142
152
  {_.id}
143
153
  </div>
144
154
  ))}
@@ -264,7 +274,7 @@ Vitest.describe('useClientDocument', () => {
264
274
  spanProcessors: [new SimpleSpanProcessor(exporter)],
265
275
  })
266
276
 
267
- const otelTracer = provider.getTracer(`testing-${strictMode ? 'strict' : 'non-strict'}`)
277
+ const otelTracer = provider.getTracer(`testing-${strictMode !== undefined ? 'strict' : 'non-strict'}`)
268
278
 
269
279
  const span = otelTracer.startSpan('test-root')
270
280
  const otelContext = otel.trace.setSpan(otel.context.active(), span)
@@ -314,7 +324,7 @@ Vitest.describe('useClientDocument', () => {
314
324
  const stackInfo = JSON.parse(val as string) as LiveStore.StackInfo
315
325
  // stackInfo.frames.shift() // Removes `renderHook.wrapper` from the stack
316
326
  stackInfo.frames.forEach((_) => {
317
- if (_.name.includes('renderHook.wrapper')) {
327
+ if (_.name.includes('renderHook.wrapper') === true) {
318
328
  _.name = 'renderHook.wrapper'
319
329
  }
320
330
  _.filePath = '__REPLACED_FOR_SNAPSHOT__'
@@ -1,10 +1,12 @@
1
+ import React from 'react'
2
+
1
3
  import type { RowQuery } from '@livestore/common'
2
4
  import { SessionIdSymbol } from '@livestore/common'
3
5
  import { State } from '@livestore/common/schema'
6
+ import { removeUndefinedValues, type StateSetters, validateTableOptions } from '@livestore/framework-toolkit'
4
7
  import type { LiveQuery, LiveQueryDef, Store } from '@livestore/livestore'
5
8
  import { queryDb } from '@livestore/livestore'
6
9
  import { omitUndefineds, shouldNeverHappen } from '@livestore/utils'
7
- import React from 'react'
8
10
 
9
11
  import { useQueryRef } from './useQuery.ts'
10
12
 
@@ -116,15 +118,13 @@ export const useClientDocument: {
116
118
 
117
119
  const store = storeArg?.store ?? shouldNeverHappen(`No store provided to useClientDocument`)
118
120
 
119
- // console.debug('useClientDocument', tableName, id)
120
-
121
121
  const idStr: string = id === SessionIdSymbol ? store.sessionId : id
122
122
 
123
123
  type QueryDef = LiveQueryDef<TTableDef['Value']>
124
124
  const queryDef: QueryDef = React.useMemo(
125
125
  () =>
126
- queryDb(table.get(id!, { default: defaultValues! }), {
127
- deps: [idStr!, table.sqliteDef.name, JSON.stringify(defaultValues)],
126
+ queryDb(table.get(id, { default: defaultValues! }), {
127
+ deps: [idStr, table.sqliteDef.name, JSON.stringify(defaultValues)],
128
128
  }),
129
129
  [table, id, defaultValues, idStr],
130
130
  )
@@ -139,60 +139,10 @@ export const useClientDocument: {
139
139
  const newValue = typeof newValueOrFn === 'function' ? newValueOrFn(queryRef.valueRef.current) : newValueOrFn
140
140
  if (queryRef.valueRef.current === newValue) return
141
141
 
142
- store.commit(table.set(removeUndefinedValues(newValue), id as any))
142
+ store.commit(table.set(removeUndefinedValues(newValue), id))
143
143
  },
144
144
  [id, queryRef.valueRef, store, table],
145
145
  )
146
146
 
147
147
  return [queryRef.valueRef.current, setState, idStr, queryRef.queryRcRef.value]
148
148
  }
149
-
150
- /**
151
- * A function that dispatches an action. Mirrors React's `Dispatch` type.
152
- * @typeParam A - The action type
153
- */
154
- export type Dispatch<A> = (action: A) => void
155
-
156
- /**
157
- * A state update that can be either a partial value or a function returning a partial value.
158
- * Used when the client-document table has `partialSet: true`.
159
- * @typeParam S - The state type
160
- */
161
- export type SetStateActionPartial<S> = Partial<S> | ((previousValue: S) => Partial<S>)
162
-
163
- /**
164
- * A state update that can be either a full value or a function returning a full value.
165
- * Mirrors React's `SetStateAction` type.
166
- * @typeParam S - The state type
167
- */
168
- export type SetStateAction<S> = S | ((previousValue: S) => S)
169
-
170
- /**
171
- * The setter function type for `useClientDocument`, determined by the table's `partialSet` option.
172
- *
173
- * - If `partialSet: false` (default), requires full state replacement
174
- * - If `partialSet: true`, accepts partial updates merged with existing state
175
- *
176
- * @typeParam TTableDef - The client-document table definition type
177
- */
178
- export type StateSetters<TTableDef extends State.SQLite.ClientDocumentTableDef.TraitAny> = Dispatch<
179
- TTableDef[State.SQLite.ClientDocumentTableDefSymbol]['options']['partialSet'] extends false
180
- ? SetStateAction<TTableDef['Value']>
181
- : SetStateActionPartial<TTableDef['Value']>
182
- >
183
-
184
- const validateTableOptions = (table: State.SQLite.TableDef<any, any>) => {
185
- if (State.SQLite.tableIsClientDocumentTable(table) === false) {
186
- return shouldNeverHappen(
187
- `useClientDocument called on table "${table.sqliteDef.name}" which is not a client document table`,
188
- )
189
- }
190
- }
191
-
192
- const removeUndefinedValues = (value: any) => {
193
- if (typeof value === 'object' && value !== null) {
194
- return Object.fromEntries(Object.entries(value).filter(([_, v]) => v !== undefined))
195
- }
196
-
197
- return value
198
- }
@@ -1,14 +1,14 @@
1
+ import * as ReactTesting from '@testing-library/react'
2
+ import React from 'react'
3
+ import * as ReactWindow from 'react-window'
4
+ import { expect } from 'vitest'
5
+
1
6
  /** biome-ignore-all lint/a11y: test */
2
7
  import * as LiveStore from '@livestore/livestore'
3
8
  import { queryDb, StoreInternalsSymbol, signal } from '@livestore/livestore'
4
9
  import { RG } from '@livestore/livestore/internal/testing-utils'
5
- import { Effect, Schema } from '@livestore/utils/effect'
6
10
  import { Vitest } from '@livestore/utils-dev/node-vitest'
7
- import * as ReactTesting from '@testing-library/react'
8
- import React from 'react'
9
- // @ts-expect-error no types
10
- import * as ReactWindow from 'react-window'
11
- import { expect } from 'vitest'
11
+ import { Effect, Schema } from '@livestore/utils/effect'
12
12
 
13
13
  import { events, makeTodoMvcReact, tables } from './__tests__/fixture.tsx'
14
14
  import { __resetUseRcResourceCache } from './useRcResource.ts'
@@ -156,7 +156,7 @@ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
156
156
  width={100}
157
157
  itemSize={10}
158
158
  itemCount={numItems}
159
- itemData={Array.from({ length: numItems }, (_, i) => i).reverse()}
159
+ itemData={Array.from({ length: numItems }, (_, i) => i).toReversed()}
160
160
  >
161
161
  {ListItem}
162
162
  </ReactWindow.FixedSizeList>
@@ -223,7 +223,7 @@ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
223
223
  const { result, rerender } = ReactTesting.renderHook(
224
224
  (useNum: boolean) => {
225
225
  renderCount.inc()
226
- const query$ = React.useMemo(() => (useNum ? num$ : str$), [useNum])
226
+ const query$ = React.useMemo(() => (useNum === true ? num$ : str$), [useNum])
227
227
  return store.useQuery(query$)
228
228
  },
229
229
  { wrapper, initialProps: false },
package/src/useQuery.ts CHANGED
@@ -1,21 +1,19 @@
1
- import { isQueryBuilder } from '@livestore/common'
2
- import type { LiveQuery, LiveQueryDef, Store } from '@livestore/livestore'
1
+ import type * as otel from '@opentelemetry/api'
2
+ import React from 'react'
3
+
3
4
  import {
4
- extractStackInfoFromStackTrace,
5
- isQueryable,
6
- type Queryable,
7
- queryDb,
8
- type SignalDef,
9
- StoreInternalsSymbol,
10
- stackInfoToString,
11
- } from '@livestore/livestore'
5
+ captureStackInfo,
6
+ computeRcRefKey,
7
+ createQueryResource,
8
+ type NormalizedQueryable,
9
+ normalizeQueryable,
10
+ runInitialQuery,
11
+ } from '@livestore/framework-toolkit'
12
+ import type { LiveQuery, Queryable, Store } from '@livestore/livestore'
12
13
  import type { LiveQueries } from '@livestore/livestore/internal'
13
- import { deepEqual, indent, shouldNeverHappen } from '@livestore/utils'
14
- import * as otel from '@opentelemetry/api'
15
- import React from 'react'
14
+ import { deepEqual, shouldNeverHappen } from '@livestore/utils'
16
15
 
17
16
  import { useRcResource } from './useRcResource.ts'
18
- import { originalStackLimit } from './utils/stack-info.ts'
19
17
  import { useStateRefWithReactiveInput } from './utils/useStateRefWithReactiveInput.ts'
20
18
 
21
19
  /**
@@ -73,124 +71,42 @@ export const useQueryRef = <TQueryable extends Queryable<any>>(
73
71
  const store = options?.store ?? shouldNeverHappen(`No store provided to useQuery`)
74
72
 
75
73
  type TResult = Queryable.Result<TQueryable>
76
- type NormalizedQueryable =
77
- | { _tag: 'definition'; def: LiveQueryDef<TResult> | SignalDef<TResult> }
78
- | { _tag: 'live-query'; query$: LiveQuery<TResult> }
79
-
80
- const normalized = React.useMemo<NormalizedQueryable>(() => {
81
- if (!isQueryable(queryable)) {
82
- return shouldNeverHappen('useQuery expected a Queryable value')
83
- }
84
-
85
- if (isQueryBuilder(queryable)) {
86
- return { _tag: 'definition', def: queryDb(queryable) }
87
- }
88
74
 
89
- if (
90
- (queryable as LiveQueryDef<TResult> | SignalDef<TResult>)._tag === 'def' ||
91
- (queryable as LiveQueryDef<TResult> | SignalDef<TResult>)._tag === 'signal-def'
92
- ) {
93
- return { _tag: 'definition', def: queryable as LiveQueryDef<TResult> | SignalDef<TResult> }
94
- }
95
-
96
- return { _tag: 'live-query', query$: queryable as LiveQuery<TResult> }
97
- }, [queryable])
98
-
99
- // It's important to use all "aspects" of a store instance here, otherwise we get unexpected cache mappings
100
- const rcRefKey = React.useMemo(() => {
101
- const base = `${store.storeId}_${store.clientId}_${store.sessionId}`
102
-
103
- if (normalized._tag === 'definition') {
104
- return `${base}:def:${normalized.def.hash}`
105
- }
106
-
107
- return `${base}:instance:${normalized.query$.id}`
108
- }, [normalized, store.clientId, store.sessionId, store.storeId])
75
+ const normalized = React.useMemo<NormalizedQueryable<TResult>>(
76
+ () => normalizeQueryable(queryable as Queryable<TResult>),
77
+ [queryable],
78
+ )
109
79
 
110
- const resourceLabel = normalized._tag === 'definition' ? normalized.def.label : normalized.query$.label
80
+ const rcRefKey = React.useMemo(() => computeRcRefKey(store, normalized), [normalized, store])
111
81
 
112
- const stackInfo = React.useMemo(() => {
113
- Error.stackTraceLimit = 10
114
- const stack = new Error().stack!
115
- Error.stackTraceLimit = originalStackLimit
116
- return extractStackInfoFromStackTrace(stack)
117
- }, [])
82
+ const stackInfo = React.useMemo(() => captureStackInfo(), [])
118
83
 
119
84
  const { queryRcRef, span, otelContext } = useRcResource(
120
85
  rcRefKey,
121
- () => {
122
- const span = store[StoreInternalsSymbol].otel.tracer.startSpan(
123
- options?.otelSpanName ?? `LiveStore:useQuery:${resourceLabel}`,
124
- { attributes: { label: resourceLabel, firstStackInfo: JSON.stringify(stackInfo) } },
125
- options?.otelContext ?? store[StoreInternalsSymbol].otel.queriesSpanContext,
126
- )
127
-
128
- const otelContext = otel.trace.setSpan(otel.context.active(), span)
129
-
130
- const queryRcRef =
131
- normalized._tag === 'definition'
132
- ? normalized.def.make(store[StoreInternalsSymbol].reactivityGraph.context!, otelContext)
133
- : ({
134
- value: normalized.query$,
135
- deref: () => {},
136
- rc: Number.POSITIVE_INFINITY,
137
- } satisfies LiveQueries.RcRef<LiveQuery<TResult>>)
138
-
139
- return { queryRcRef, span, otelContext }
140
- },
86
+ () =>
87
+ createQueryResource(store, normalized, stackInfo, {
88
+ otelSpanName: options?.otelSpanName,
89
+ otelContext: options?.otelContext,
90
+ }),
141
91
  // We need to keep the queryRcRef alive a bit longer, so we have a second `useRcResource` below
142
92
  // which takes care of disposing the queryRcRef
143
93
  () => {},
144
94
  )
145
95
 
146
- // if (queryRcRef.value._tag === 'signal') {
147
- // const queryRcRef.value.get()
148
- // }
149
-
150
- const query$ = queryRcRef.value as LiveQuery<TResult>
96
+ const query$ = queryRcRef.value
151
97
 
152
98
  React.useDebugValue(`LiveStore:useQuery:${query$.id}:${query$.label}`)
153
- // console.debug(`LiveStore:useQuery:${query$.id}:${query$.label}`)
154
-
155
- const initialResult = React.useMemo(() => {
156
- try {
157
- return query$.run({
158
- otelContext,
159
- debugRefreshReason: {
160
- _tag: 'react',
161
- api: 'useQuery',
162
- label: `useQuery:initial-run:${query$.label}`,
163
- stackInfo,
164
- },
165
- })
166
- } catch (cause: any) {
167
- console.error('[@livestore/react:useQuery] Error running query', cause)
168
- throw new Error(
169
- `\
170
- [@livestore/react:useQuery] Error running query: ${cause.name}
171
99
 
172
- Query: ${query$.label}
173
-
174
- React trace:
175
-
176
- ${indent(stackInfoToString(stackInfo), 4)}
177
-
178
- Stack trace:
179
- `,
180
- { cause },
181
- )
182
- }
183
- }, [otelContext, query$, stackInfo])
100
+ const initialResult = React.useMemo(
101
+ () => runInitialQuery(query$, otelContext, stackInfo, 'react'),
102
+ [otelContext, query$, stackInfo],
103
+ )
184
104
 
185
105
  // We know the query has a result by the time we use it; so we can synchronously populate a default state
186
106
  const [valueRef, setValue] = useStateRefWithReactiveInput<TResult>(initialResult)
187
107
 
188
- // TODO we probably need to change the order of `useEffect` calls, so we destroy the query at the end
189
- // before calling the LS `onEffect` on it
190
-
191
108
  // Subscribe to future updates for this query
192
109
  React.useEffect(() => {
193
- // TODO double check whether we still need `activeSubscriptions`
194
110
  query$.activeSubscriptions.add(stackInfo)
195
111
 
196
112
  // Dynamic queries only set their actual label after they've been run the first time,
@@ -221,7 +137,6 @@ Stack trace:
221
137
  rcRefKey,
222
138
  () => ({ queryRcRef, span }),
223
139
  ({ queryRcRef, span }) => {
224
- // console.debug('deref', queryRcRef.value.id, queryRcRef.value.label)
225
140
  queryRcRef.deref()
226
141
  span.end()
227
142
  },
@@ -9,7 +9,7 @@ describe.each([{ strictMode: true }, { strictMode: false }])('useRcResource (str
9
9
  __resetUseRcResourceCache()
10
10
  })
11
11
 
12
- const wrapper = strictMode ? React.StrictMode : React.Fragment
12
+ const wrapper = strictMode === true ? React.StrictMode : React.Fragment
13
13
 
14
14
  it('should create a stateful entity using make and call cleanup on unmount', () => {
15
15
  const makeSpy = vi.fn(() => Symbol('statefulResource'))
@@ -79,11 +79,16 @@ export const useRcResource = <T>(
79
79
  ): T => {
80
80
  const keyRef = React.useRef<string | undefined>(undefined)
81
81
  const didDisposeInMemo = React.useRef(false)
82
+ const createRef = React.useRef(create)
83
+ const disposeRef = React.useRef(dispose)
84
+
85
+ createRef.current = create
86
+ disposeRef.current = dispose
82
87
 
83
88
  // biome-ignore lint/correctness/useExhaustiveDependencies: Dependency is deliberately limited to `key` to avoid unintended re-creations.
84
89
  const resource = React.useMemo(() => {
85
90
  // console.debug('useMemo', key)
86
- if (didDisposeInMemo.current) {
91
+ if (didDisposeInMemo.current === true) {
87
92
  // console.debug('useMemo', key, 'skip')
88
93
  const cachedItem = cache.get(key)
89
94
  if (cachedItem !== undefined && cachedItem._tag === 'active') {
@@ -104,7 +109,7 @@ export const useRcResource = <T>(
104
109
 
105
110
  if (cachedItemForPreviousKey.rc === 0) {
106
111
  // Clean up the stateful resource if no longer referenced
107
- dispose(cachedItemForPreviousKey.resource)
112
+ disposeRef.current(cachedItemForPreviousKey.resource)
108
113
  cache.set(previousKey, { _tag: 'destroyed' })
109
114
  didDisposeInMemo.current = true
110
115
  }
@@ -122,7 +127,7 @@ export const useRcResource = <T>(
122
127
  }
123
128
 
124
129
  // Create a new stateful resource if not cached
125
- const resource = create()
130
+ const resource = createRef.current()
126
131
  cache.set(key, { _tag: 'active', rc: 1, resource })
127
132
  return resource
128
133
  }, [key])
@@ -130,7 +135,7 @@ export const useRcResource = <T>(
130
135
  // biome-ignore lint/correctness/useExhaustiveDependencies: We assume the `dispose` function is stable and won't change across renders
131
136
  React.useEffect(() => {
132
137
  return () => {
133
- if (didDisposeInMemo.current) {
138
+ if (didDisposeInMemo.current === true) {
134
139
  // console.debug('unmount', keyRef.current, 'skip')
135
140
  didDisposeInMemo.current = false
136
141
  return
@@ -146,7 +151,7 @@ export const useRcResource = <T>(
146
151
  // console.debug('rc--', cachedItem.rc, ...(_options?.debugPrint?.(cachedItem.resource) ?? []))
147
152
 
148
153
  if (cachedItem.rc === 0) {
149
- dispose(cachedItem.resource)
154
+ disposeRef.current(cachedItem.resource)
150
155
  cache.delete(key)
151
156
  }
152
157
  }