@livestore/react 0.4.0-dev.9 → 0.4.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 (85) hide show
  1. package/README.md +1 -1
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/StoreRegistryContext.d.ts +56 -0
  4. package/dist/StoreRegistryContext.d.ts.map +1 -0
  5. package/dist/StoreRegistryContext.js +61 -0
  6. package/dist/StoreRegistryContext.js.map +1 -0
  7. package/dist/__tests__/fixture.d.ts +12 -283
  8. package/dist/__tests__/fixture.d.ts.map +1 -1
  9. package/dist/__tests__/fixture.js +12 -86
  10. package/dist/__tests__/fixture.js.map +1 -1
  11. package/dist/experimental/components/LiveList.d.ts +4 -2
  12. package/dist/experimental/components/LiveList.d.ts.map +1 -1
  13. package/dist/experimental/components/LiveList.js +10 -8
  14. package/dist/experimental/components/LiveList.js.map +1 -1
  15. package/dist/mod.d.ts +8 -5
  16. package/dist/mod.d.ts.map +1 -1
  17. package/dist/mod.js +6 -4
  18. package/dist/mod.js.map +1 -1
  19. package/dist/useClientDocument.d.ts +12 -4
  20. package/dist/useClientDocument.d.ts.map +1 -1
  21. package/dist/useClientDocument.js +4 -18
  22. package/dist/useClientDocument.js.map +1 -1
  23. package/dist/useClientDocument.test.js +21 -9
  24. package/dist/useClientDocument.test.js.map +1 -1
  25. package/dist/useQuery.d.ts +29 -8
  26. package/dist/useQuery.d.ts.map +1 -1
  27. package/dist/useQuery.js +43 -70
  28. package/dist/useQuery.js.map +1 -1
  29. package/dist/useQuery.test.js +57 -13
  30. package/dist/useQuery.test.js.map +1 -1
  31. package/dist/useRcResource.d.ts +1 -1
  32. package/dist/useRcResource.d.ts.map +1 -1
  33. package/dist/useRcResource.js +41 -34
  34. package/dist/useRcResource.js.map +1 -1
  35. package/dist/useRcResource.test.js +72 -50
  36. package/dist/useRcResource.test.js.map +1 -1
  37. package/dist/useStore.d.ts +74 -7
  38. package/dist/useStore.d.ts.map +1 -1
  39. package/dist/useStore.js +82 -16
  40. package/dist/useStore.js.map +1 -1
  41. package/dist/useStore.test.d.ts +2 -0
  42. package/dist/useStore.test.d.ts.map +1 -0
  43. package/dist/useStore.test.js +241 -0
  44. package/dist/useStore.test.js.map +1 -0
  45. package/dist/useSyncStatus.d.ts +22 -0
  46. package/dist/useSyncStatus.d.ts.map +1 -0
  47. package/dist/useSyncStatus.js +28 -0
  48. package/dist/useSyncStatus.js.map +1 -0
  49. package/package.json +69 -26
  50. package/src/StoreRegistryContext.tsx +70 -0
  51. package/src/__snapshots__/useClientDocument.test.tsx.snap +112 -78
  52. package/src/__snapshots__/useQuery.test.tsx.snap +12 -12
  53. package/src/__tests__/fixture.tsx +29 -133
  54. package/src/experimental/components/LiveList.tsx +23 -10
  55. package/src/mod.ts +8 -12
  56. package/src/useClientDocument.test.tsx +90 -79
  57. package/src/useClientDocument.ts +19 -38
  58. package/src/useQuery.test.tsx +99 -13
  59. package/src/useQuery.ts +74 -89
  60. package/src/useRcResource.test.tsx +115 -59
  61. package/src/useRcResource.ts +51 -37
  62. package/src/useStore.test.tsx +347 -0
  63. package/src/useStore.ts +115 -22
  64. package/src/useSyncStatus.ts +34 -0
  65. package/dist/LiveStoreContext.d.ts +0 -13
  66. package/dist/LiveStoreContext.d.ts.map +0 -1
  67. package/dist/LiveStoreContext.js +0 -3
  68. package/dist/LiveStoreContext.js.map +0 -1
  69. package/dist/LiveStoreProvider.d.ts +0 -65
  70. package/dist/LiveStoreProvider.d.ts.map +0 -1
  71. package/dist/LiveStoreProvider.js +0 -221
  72. package/dist/LiveStoreProvider.js.map +0 -1
  73. package/dist/LiveStoreProvider.test.d.ts +0 -2
  74. package/dist/LiveStoreProvider.test.d.ts.map +0 -1
  75. package/dist/LiveStoreProvider.test.js +0 -117
  76. package/dist/LiveStoreProvider.test.js.map +0 -1
  77. package/dist/utils/stack-info.d.ts +0 -4
  78. package/dist/utils/stack-info.d.ts.map +0 -1
  79. package/dist/utils/stack-info.js +0 -10
  80. package/dist/utils/stack-info.js.map +0 -1
  81. package/src/LiveStoreContext.ts +0 -14
  82. package/src/LiveStoreProvider.test.tsx +0 -248
  83. package/src/LiveStoreProvider.tsx +0 -413
  84. package/src/ambient.d.ts +0 -1
  85. package/src/utils/stack-info.ts +0 -13
@@ -1,131 +1,44 @@
1
- import { makeInMemoryAdapter } from '@livestore/adapter-web'
2
- import { provideOtel, type UnexpectedError } 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
- import * as LiveStoreReact from '../mod.js'
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'
18
+ // Re-export shared types and schema
19
+ export { events, schema, tables }
20
+ export type { AppState, Filter, Todo }
20
21
 
21
- export type AppState = {
22
- newTodoText: string
23
- filter: Filter
22
+ export type MakeTodoMvcReactOptions = CreateTodoMvcStoreOptions & {
23
+ strictMode?: boolean | undefined
24
24
  }
25
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 })
97
-
98
- export const makeTodoMvcReact: ({
99
- otelTracer,
100
- otelContext,
101
- strictMode,
102
- }?: {
103
- otelTracer?: otel.Tracer
104
- otelContext?: otel.Context
105
- strictMode?: boolean
106
- }) => Effect.Effect<
26
+ export const makeTodoMvcReact: (opts?: MakeTodoMvcReactOptions) => Effect.Effect<
107
27
  {
108
28
  wrapper: ({ children }: any) => React.JSX.Element
109
- store: Store<LiveStoreSchema<SqliteDsl.DbSchema, State.SQLite.EventDefRecord>, {}> & LiveStoreReact.ReactApi
29
+ store: Store<typeof schema> & LiveStoreReact.ReactApi
110
30
  renderCount: { readonly val: number; inc: () => void }
111
31
  },
112
- UnexpectedError,
32
+ UnknownError,
113
33
  Scope.Scope
114
- > = ({
115
- otelTracer,
116
- otelContext,
117
- strictMode,
118
- }: {
119
- otelTracer?: otel.Tracer
120
- otelContext?: otel.Context
121
- strictMode?: boolean
122
- } = {}) =>
34
+ > = (opts: MakeTodoMvcReactOptions = {}) =>
123
35
  Effect.gen(function* () {
36
+ const { strictMode } = opts
124
37
  const makeRenderCount = () => {
125
38
  let val = 0
126
39
 
127
40
  const inc = () => {
128
- val += strictMode ? 0.5 : 1
41
+ val += strictMode === true ? 0.5 : 1
129
42
  }
130
43
 
131
44
  return {
@@ -136,32 +49,15 @@ export const makeTodoMvcReact: ({
136
49
  }
137
50
  }
138
51
 
139
- const store: Store<any> = yield* createStore({
140
- schema,
141
- storeId: 'default',
142
- adapter: makeInMemoryAdapter(),
143
- debug: { instanceId: 'test' },
144
- })
52
+ const store = yield* createTodoMvcStore(opts)
145
53
 
146
54
  const storeWithReactApi = LiveStoreReact.withReactApi(store)
147
55
 
148
- // TODO improve typing of `LiveStoreContext`
149
- const storeContext = {
150
- stage: 'running' as const,
151
- store: storeWithReactApi,
152
- }
153
-
154
- const MaybeStrictMode = strictMode ? React.StrictMode : React.Fragment
56
+ const MaybeStrictMode = strictMode === true ? React.StrictMode : React.Fragment
155
57
 
156
- const wrapper = ({ children }: any) => (
157
- <MaybeStrictMode>
158
- <LiveStoreReact.LiveStoreContext.Provider value={storeContext}>
159
- {children}
160
- </LiveStoreReact.LiveStoreContext.Provider>
161
- </MaybeStrictMode>
162
- )
58
+ const wrapper = ({ children }: any) => <MaybeStrictMode>{children}</MaybeStrictMode>
163
59
 
164
60
  const renderCount = makeRenderCount()
165
61
 
166
62
  return { wrapper, store: storeWithReactApi, renderCount }
167
- }).pipe(provideOtel(omitUndefineds({ parentSpanContext: otelContext, otelTracer })))
63
+ })
@@ -1,8 +1,8 @@
1
- import type { LiveQueryDef } from '@livestore/livestore'
1
+ import type { LiveQueryDef, Store } from '@livestore/livestore'
2
2
  import { computed } from '@livestore/livestore'
3
3
  import React from 'react'
4
4
 
5
- import { useQuery } from '../../useQuery.js'
5
+ import { useQuery } from '../../useQuery.ts'
6
6
 
7
7
  /*
8
8
  TODO:
@@ -16,6 +16,8 @@ export type LiveListProps<TItem> = {
16
16
  renderItem: (item: TItem, opts: { index: number; isInitialListRender: boolean }) => React.ReactNode
17
17
  /** Needs to be unique across all list items */
18
18
  getKey: (item: TItem, index: number) => string | number
19
+ /** The store instance to use for queries */
20
+ store: Store<any, any>
19
21
  }
20
22
 
21
23
  /**
@@ -26,12 +28,15 @@ export type LiveListProps<TItem> = {
26
28
  * In the future we want to make this component even more efficient by using incremental rendering (https://github.com/livestorejs/livestore/pull/55)
27
29
  * e.g. when an item is added/removed/moved to only re-render the affected DOM nodes.
28
30
  */
29
- export const LiveList = <TItem,>({ items$, renderItem, getKey }: LiveListProps<TItem>): React.ReactNode => {
31
+ export const LiveList = <TItem,>({ items$, renderItem, getKey, store }: LiveListProps<TItem>): React.ReactNode => {
30
32
  const [hasMounted, setHasMounted] = React.useState(false)
31
33
 
32
34
  React.useEffect(() => setHasMounted(true), [])
33
35
 
34
- const keys = useQuery(computed((get) => get(items$).map(getKey)))
36
+ const keys = useQuery(
37
+ computed((get) => get(items$).map(getKey)),
38
+ { store },
39
+ )
35
40
  const arr = React.useMemo(
36
41
  () =>
37
42
  keys.map(
@@ -54,7 +59,9 @@ export const LiveList = <TItem,>({ items$, renderItem, getKey }: LiveListProps<T
54
59
  key={key}
55
60
  itemKey={key}
56
61
  item$={item$}
57
- opts={{ isInitialListRender: !hasMounted, index }}
62
+ store={store}
63
+ index={index}
64
+ isInitialListRender={!hasMounted}
58
65
  renderItem={renderItem}
59
66
  />
60
67
  ))}
@@ -64,15 +71,20 @@ export const LiveList = <TItem,>({ items$, renderItem, getKey }: LiveListProps<T
64
71
 
65
72
  const ItemWrapper = <TItem,>({
66
73
  item$,
67
- opts,
74
+ index,
75
+ isInitialListRender,
68
76
  renderItem,
77
+ store,
69
78
  }: {
70
79
  itemKey: string | number
71
80
  item$: LiveQueryDef<TItem>
72
- opts: { index: number; isInitialListRender: boolean }
81
+ index: number
82
+ isInitialListRender: boolean
73
83
  renderItem: (item: TItem, opts: { index: number; isInitialListRender: boolean }) => React.ReactNode
84
+ store: Store<any, any>
74
85
  }) => {
75
- const item = useQuery(item$)
86
+ const item = useQuery(item$, { store })
87
+ const opts = React.useMemo(() => ({ index, isInitialListRender }), [index, isInitialListRender])
76
88
 
77
89
  return <>{renderItem(item, opts)}</>
78
90
  }
@@ -82,6 +94,7 @@ const ItemWrapperMemo = React.memo(
82
94
  (prev, next) =>
83
95
  prev.itemKey === next.itemKey &&
84
96
  prev.renderItem === next.renderItem &&
85
- prev.opts.index === next.opts.index &&
86
- prev.opts.isInitialListRender === next.opts.isInitialListRender,
97
+ prev.store === next.store &&
98
+ prev.index === next.index &&
99
+ prev.isInitialListRender === next.isInitialListRender,
87
100
  ) as typeof ItemWrapper
package/src/mod.ts CHANGED
@@ -1,13 +1,9 @@
1
- export { LiveStoreContext, type ReactApi } from './LiveStoreContext.ts'
2
- export { LiveStoreProvider } from './LiveStoreProvider.tsx'
3
- export {
4
- type Dispatch,
5
- type SetStateAction,
6
- type SetStateActionPartial,
7
- type StateSetters,
8
- type UseClientDocumentResult,
9
- useClientDocument,
10
- } from './useClientDocument.ts'
1
+ export type { Dispatch, SetStateAction, SetStateActionPartial, StateSetters } from '@livestore/framework-toolkit'
2
+ export { captureStackInfo } from '@livestore/framework-toolkit'
3
+ export { StoreRegistry, storeOptions } from '@livestore/livestore'
4
+ export { LiveList, type LiveListProps } from './experimental/components/LiveList.tsx'
5
+ export * from './StoreRegistryContext.tsx'
6
+ export { type UseClientDocumentResult, useClientDocument } from './useClientDocument.ts'
11
7
  export { useQuery, useQueryRef } from './useQuery.ts'
12
- export { useStore, withReactApi } from './useStore.ts'
13
- export { useStackInfo } from './utils/stack-info.ts'
8
+ export { type ReactApi, useStore, withReactApi } from './useStore.ts'
9
+ export { useSyncStatus } from './useSyncStatus.ts'
@@ -1,18 +1,19 @@
1
1
  /** biome-ignore-all lint/a11y/useValidAriaRole: not needed for testing */
2
2
  /** biome-ignore-all lint/a11y/noStaticElementInteractions: not needed for testing */
3
3
  import * as LiveStore from '@livestore/livestore'
4
+ import { StoreInternalsSymbol } from '@livestore/livestore'
4
5
  import { getAllSimplifiedRootSpans, getSimplifiedRootSpan } from '@livestore/livestore/internal/testing-utils'
5
6
  import { Effect, ReadonlyRecord, Schema } from '@livestore/utils/effect'
6
7
  import { Vitest } from '@livestore/utils-dev/node-vitest'
7
8
  import * as otel from '@opentelemetry/api'
8
9
  import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'
9
10
  import * as ReactTesting from '@testing-library/react'
10
- import type React from 'react'
11
+ import * as React from 'react'
11
12
  import { beforeEach, expect, it } from 'vitest'
12
13
 
13
- import { events, makeTodoMvcReact, tables } from './__tests__/fixture.js'
14
- import type * as LiveStoreReact from './mod.js'
15
- import { __resetUseRcResourceCache } from './useRcResource.js'
14
+ import { events, makeTodoMvcReact, tables } from './__tests__/fixture.tsx'
15
+ import type * as LiveStoreReact from './mod.ts'
16
+ import { __resetUseRcResourceCache } from './useRcResource.ts'
16
17
 
17
18
  // const strictMode = process.env.REACT_STRICT_MODE !== undefined
18
19
 
@@ -39,12 +40,12 @@ Vitest.describe('useClientDocument', () => {
39
40
  expect(result.current.id).toBe('u1')
40
41
  expect(result.current.state.username).toBe('')
41
42
  expect(renderCount.val).toBe(1)
42
- expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
43
+ expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
43
44
  store.commit(tables.userInfo.set({ username: 'username_u2' }, 'u2'))
44
45
 
45
46
  rerender('u2')
46
47
 
47
- expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
48
+ expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
48
49
  expect(result.current.id).toBe('u2')
49
50
  expect(result.current.state.username).toBe('username_u2')
50
51
  expect(renderCount.val).toBe(2)
@@ -119,25 +120,35 @@ Vitest.describe('useClientDocument', () => {
119
120
  renderCount.inc()
120
121
 
121
122
  const [state, setState] = store.useClientDocument(tables.AppRouterSchema, 'singleton')
123
+ const setCurrentTaskId = React.useCallback((taskId: string) => setState({ currentTaskId: taskId }), [setState])
122
124
 
123
125
  globalSetState = setState
124
126
 
125
127
  return (
126
128
  <div>
127
- <TasksList setTaskId={(taskId) => setState({ currentTaskId: taskId })} />
129
+ <TasksList setTaskId={setCurrentTaskId} />
128
130
  <div role="current-id">Current Task Id: {state.currentTaskId ?? '-'}</div>
129
- {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>}
130
132
  </div>
131
133
  )
132
134
  }
133
135
 
134
136
  const TasksList: React.FC<{ setTaskId: (_: string) => void }> = ({ setTaskId }) => {
135
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
+ )
136
147
 
137
148
  return (
138
149
  <div>
139
150
  {allTodos.map((_) => (
140
- <div key={_.id} onClick={() => setTaskId(_.id)}>
151
+ <div key={_.id} data-task-id={_.id} onClick={handleTaskClick}>
141
152
  {_.id}
142
153
  </div>
143
154
  ))}
@@ -253,81 +264,81 @@ Vitest.describe('useClientDocument', () => {
253
264
  )
254
265
 
255
266
  Vitest.describe('otel', () => {
256
- it.each([{ strictMode: true }, { strictMode: false }])(
257
- 'should update the data based on component key strictMode=%s',
258
- async ({ strictMode }) => {
259
- const exporter = new InMemorySpanExporter()
260
-
261
- const provider = new BasicTracerProvider({
262
- spanProcessors: [new SimpleSpanProcessor(exporter)],
267
+ it.each([
268
+ { strictMode: true },
269
+ { strictMode: false },
270
+ ])('should update the data based on component key strictMode=%s', async ({ strictMode }) => {
271
+ const exporter = new InMemorySpanExporter()
272
+
273
+ const provider = new BasicTracerProvider({
274
+ spanProcessors: [new SimpleSpanProcessor(exporter)],
275
+ })
276
+
277
+ const otelTracer = provider.getTracer(`testing-${strictMode !== undefined ? 'strict' : 'non-strict'}`)
278
+
279
+ const span = otelTracer.startSpan('test-root')
280
+ const otelContext = otel.trace.setSpan(otel.context.active(), span)
281
+
282
+ await Effect.gen(function* () {
283
+ const { wrapper, store, renderCount } = yield* makeTodoMvcReact({
284
+ otelContext,
285
+ otelTracer,
286
+ strictMode,
263
287
  })
264
288
 
265
- const otelTracer = provider.getTracer(`testing-${strictMode ? 'strict' : 'non-strict'}`)
266
-
267
- const span = otelTracer.startSpan('test-root')
268
- const otelContext = otel.trace.setSpan(otel.context.active(), span)
269
-
270
- await Effect.gen(function* () {
271
- const { wrapper, store, renderCount } = yield* makeTodoMvcReact({
272
- otelContext,
273
- otelTracer,
274
- strictMode,
275
- })
276
-
277
- const { result, rerender, unmount } = ReactTesting.renderHook(
278
- (userId: string) => {
279
- renderCount.inc()
289
+ const { result, rerender, unmount } = ReactTesting.renderHook(
290
+ (userId: string) => {
291
+ renderCount.inc()
280
292
 
281
- const [state, setState, id] = store.useClientDocument(tables.userInfo, userId)
282
- return { state, setState, id }
283
- },
284
- { wrapper, initialProps: 'u1' },
285
- )
293
+ const [state, setState, id] = store.useClientDocument(tables.userInfo, userId)
294
+ return { state, setState, id }
295
+ },
296
+ { wrapper, initialProps: 'u1' },
297
+ )
286
298
 
287
- expect(result.current.id).toBe('u1')
288
- expect(result.current.state.username).toBe('')
289
- expect(renderCount.val).toBe(1)
290
-
291
- // For u2 we'll make sure that the row already exists,
292
- // so the lazy `insert` will be skipped
293
- ReactTesting.act(() => store.commit(events.UserInfoSet({ username: 'username_u2' }, 'u2')))
294
-
295
- rerender('u2')
296
-
297
- expect(result.current.id).toBe('u2')
298
- expect(result.current.state.username).toBe('username_u2')
299
- expect(renderCount.val).toBe(2)
300
-
301
- unmount()
302
- span.end()
303
- }).pipe(Effect.scoped, Effect.tapCauseLogPretty, Effect.runPromise)
304
-
305
- await provider.forceFlush()
306
-
307
- const mapAttributes = (attributes: otel.Attributes) => {
308
- return ReadonlyRecord.map(attributes, (val, key) => {
309
- if (key === 'code.stacktrace') {
310
- return '<STACKTRACE>'
311
- } else if (key === 'firstStackInfo') {
312
- const stackInfo = JSON.parse(val as string) as LiveStore.StackInfo
313
- // stackInfo.frames.shift() // Removes `renderHook.wrapper` from the stack
314
- stackInfo.frames.forEach((_) => {
315
- if (_.name.includes('renderHook.wrapper')) {
316
- _.name = 'renderHook.wrapper'
317
- }
318
- _.filePath = '__REPLACED_FOR_SNAPSHOT__'
319
- })
320
- return JSON.stringify(stackInfo)
321
- }
322
- return val
323
- })
324
- }
299
+ expect(result.current.id).toBe('u1')
300
+ expect(result.current.state.username).toBe('')
301
+ expect(renderCount.val).toBe(1)
302
+
303
+ // For u2 we'll make sure that the row already exists,
304
+ // so the lazy `insert` will be skipped
305
+ ReactTesting.act(() => store.commit(events.UserInfoSet({ username: 'username_u2' }, 'u2')))
306
+
307
+ rerender('u2')
308
+
309
+ expect(result.current.id).toBe('u2')
310
+ expect(result.current.state.username).toBe('username_u2')
311
+ expect(renderCount.val).toBe(2)
312
+
313
+ unmount()
314
+ span.end()
315
+ }).pipe(Effect.scoped, Effect.tapCauseLogPretty, Effect.runPromise)
316
+
317
+ await provider.forceFlush()
318
+
319
+ const mapAttributes = (attributes: otel.Attributes) => {
320
+ return ReadonlyRecord.map(attributes, (val, key) => {
321
+ if (key === 'code.stacktrace') {
322
+ return '<STACKTRACE>'
323
+ } else if (key === 'firstStackInfo') {
324
+ const stackInfo = JSON.parse(val as string) as LiveStore.StackInfo
325
+ // stackInfo.frames.shift() // Removes `renderHook.wrapper` from the stack
326
+ stackInfo.frames.forEach((_) => {
327
+ if (_.name.includes('renderHook.wrapper') === true) {
328
+ _.name = 'renderHook.wrapper'
329
+ }
330
+ _.filePath = '__REPLACED_FOR_SNAPSHOT__'
331
+ })
332
+ return JSON.stringify(stackInfo)
333
+ }
334
+ return val
335
+ })
336
+ }
325
337
 
326
- expect(getSimplifiedRootSpan(exporter, 'createStore', mapAttributes)).toMatchSnapshot()
327
- expect(getAllSimplifiedRootSpans(exporter, 'LiveStore:commit', mapAttributes)).toMatchSnapshot()
338
+ expect(getSimplifiedRootSpan(exporter, 'createStore', mapAttributes)).toMatchSnapshot()
339
+ expect(getAllSimplifiedRootSpans(exporter, 'LiveStore:commit', mapAttributes)).toMatchSnapshot()
328
340
 
329
- await provider.shutdown()
330
- },
331
- )
341
+ await provider.shutdown()
342
+ })
332
343
  })
333
344
  })
@@ -1,14 +1,26 @@
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
- import { LiveStoreContext } from './LiveStoreContext.ts'
10
11
  import { useQueryRef } from './useQuery.ts'
11
12
 
13
+ /**
14
+ * Return type of `useClientDocument` hook.
15
+ *
16
+ * A tuple providing React-style state access to a client-document table row:
17
+ * - `[0]` row: The current value (decoded according to the table schema)
18
+ * - `[1]` setRow: Setter function to update the document
19
+ * - `[2]` id: The document's ID (resolved from `SessionIdSymbol` if applicable)
20
+ * - `[3]` query$: The underlying `LiveQuery` for advanced use cases
21
+ *
22
+ * @typeParam TTableDef - The client-document table definition type
23
+ */
12
24
  export type UseClientDocumentResult<TTableDef extends State.SQLite.ClientDocumentTableDef.TraitAny> = [
13
25
  row: TTableDef['Value'],
14
26
  setRow: StateSetters<TTableDef>,
@@ -104,20 +116,15 @@ export const useClientDocument: {
104
116
 
105
117
  const tableName = table.sqliteDef.name
106
118
 
107
- const store =
108
- storeArg?.store ?? // biome-ignore lint/correctness/useHookAtTopLevel: store is stable
109
- React.useContext(LiveStoreContext)?.store ??
110
- shouldNeverHappen(`No store provided to useClientDocument`)
119
+ const store = storeArg?.store ?? shouldNeverHappen(`No store provided to useClientDocument`)
111
120
 
112
- // console.debug('useClientDocument', tableName, id)
113
-
114
- const idStr: string = id === SessionIdSymbol ? store.clientSession.sessionId : id
121
+ const idStr: string = id === SessionIdSymbol ? store.sessionId : id
115
122
 
116
123
  type QueryDef = LiveQueryDef<TTableDef['Value']>
117
124
  const queryDef: QueryDef = React.useMemo(
118
125
  () =>
119
- queryDb(table.get(id!, { default: defaultValues! }), {
120
- deps: [idStr!, table.sqliteDef.name, JSON.stringify(defaultValues)],
126
+ queryDb(table.get(id, { default: defaultValues! }), {
127
+ deps: [idStr, table.sqliteDef.name, JSON.stringify(defaultValues)],
121
128
  }),
122
129
  [table, id, defaultValues, idStr],
123
130
  )
@@ -132,36 +139,10 @@ export const useClientDocument: {
132
139
  const newValue = typeof newValueOrFn === 'function' ? newValueOrFn(queryRef.valueRef.current) : newValueOrFn
133
140
  if (queryRef.valueRef.current === newValue) return
134
141
 
135
- store.commit(table.set(removeUndefinedValues(newValue), id as any))
142
+ store.commit(table.set(removeUndefinedValues(newValue), id))
136
143
  },
137
144
  [id, queryRef.valueRef, store, table],
138
145
  )
139
146
 
140
147
  return [queryRef.valueRef.current, setState, idStr, queryRef.queryRcRef.value]
141
148
  }
142
-
143
- export type Dispatch<A> = (action: A) => void
144
- export type SetStateActionPartial<S> = Partial<S> | ((previousValue: S) => Partial<S>)
145
- export type SetStateAction<S> = S | ((previousValue: S) => S)
146
-
147
- export type StateSetters<TTableDef extends State.SQLite.ClientDocumentTableDef.TraitAny> = Dispatch<
148
- TTableDef[State.SQLite.ClientDocumentTableDefSymbol]['options']['partialSet'] extends false
149
- ? SetStateAction<TTableDef['Value']>
150
- : SetStateActionPartial<TTableDef['Value']>
151
- >
152
-
153
- const validateTableOptions = (table: State.SQLite.TableDef<any, any>) => {
154
- if (State.SQLite.tableIsClientDocumentTable(table) === false) {
155
- return shouldNeverHappen(
156
- `useClientDocument called on table "${table.sqliteDef.name}" which is not a client document table`,
157
- )
158
- }
159
- }
160
-
161
- const removeUndefinedValues = (value: any) => {
162
- if (typeof value === 'object' && value !== null) {
163
- return Object.fromEntries(Object.entries(value).filter(([_, v]) => v !== undefined))
164
- }
165
-
166
- return value
167
- }