@livestore/react 0.4.0-dev.8 → 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 +68 -25
  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,17 +1,19 @@
1
1
  /** biome-ignore-all lint/a11y: test */
2
+ import { makeInMemoryAdapter } from '@livestore/adapter-web'
3
+ import { provideOtel } from '@livestore/common'
2
4
  import * as LiveStore from '@livestore/livestore'
3
- import { queryDb, signal } from '@livestore/livestore'
5
+ import { createStore, queryDb, StoreInternalsSymbol, signal } from '@livestore/livestore'
4
6
  import { RG } from '@livestore/livestore/internal/testing-utils'
5
7
  import { Effect, Schema } from '@livestore/utils/effect'
6
8
  import { Vitest } from '@livestore/utils-dev/node-vitest'
7
9
  import * as ReactTesting from '@testing-library/react'
8
10
  import React from 'react'
9
- // @ts-expect-error no types
10
11
  import * as ReactWindow from 'react-window'
11
12
  import { expect } from 'vitest'
12
13
 
13
- import { events, makeTodoMvcReact, tables } from './__tests__/fixture.js'
14
- import { __resetUseRcResourceCache } from './useRcResource.js'
14
+ import { events, makeTodoMvcReact, schema, tables } from './__tests__/fixture.tsx'
15
+ import { __resetUseRcResourceCache } from './useRcResource.ts'
16
+ import { withReactApi } from './useStore.ts'
15
17
 
16
18
  Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
17
19
  'useQuery (strictMode=%s)',
@@ -38,14 +40,14 @@ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
38
40
 
39
41
  expect(result.current.length).toBe(0)
40
42
  expect(renderCount.val).toBe(1)
41
- expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
43
+ expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
42
44
 
43
45
  ReactTesting.act(() => store.commit(events.todoCreated({ id: 't1', text: 'buy milk', completed: false })))
44
46
 
45
47
  expect(result.current.length).toBe(1)
46
48
  expect(result.current[0]!.text).toBe('buy milk')
47
49
  expect(renderCount.val).toBe(2)
48
- expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
50
+ expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
49
51
  }),
50
52
  )
51
53
 
@@ -80,19 +82,25 @@ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
80
82
 
81
83
  expect(result.current).toBe('buy milk')
82
84
  expect(renderCount.val).toBe(1)
83
- expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot('1: after first render')
85
+ expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
86
+ '1: after first render',
87
+ )
84
88
 
85
89
  ReactTesting.act(() => store.commit(events.todoUpdated({ id: 't1', text: 'buy soy milk' })))
86
90
 
87
91
  expect(result.current).toBe('buy soy milk')
88
92
  expect(renderCount.val).toBe(2)
89
- expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot('2: after first commit')
93
+ expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
94
+ '2: after first commit',
95
+ )
90
96
 
91
97
  rerender('t2')
92
98
 
93
99
  expect(result.current).toBe('buy eggs')
94
100
  expect(renderCount.val).toBe(3)
95
- expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot('3: after forced rerender')
101
+ expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot(
102
+ '3: after forced rerender',
103
+ )
96
104
  }),
97
105
  )
98
106
 
@@ -120,19 +128,19 @@ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
120
128
 
121
129
  expect(result.current).toBe('buy milk')
122
130
  expect(renderCount.val).toBe(1)
123
- expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
131
+ expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
124
132
 
125
133
  ReactTesting.act(() => store.commit(events.todoUpdated({ id: 't1', text: 'buy soy milk' })))
126
134
 
127
135
  expect(result.current).toBe('buy soy milk')
128
136
  expect(renderCount.val).toBe(2)
129
- expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
137
+ expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
130
138
 
131
139
  ReactTesting.act(() => store.setSignal(filter$, 't2'))
132
140
 
133
141
  expect(result.current).toBe('buy eggs')
134
142
  expect(renderCount.val).toBe(3)
135
- expect(store.reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
143
+ expect(store[StoreInternalsSymbol].reactivityGraph.getSnapshot({ includeResults: true })).toMatchSnapshot()
136
144
  }),
137
145
  )
138
146
 
@@ -150,7 +158,7 @@ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
150
158
  width={100}
151
159
  itemSize={10}
152
160
  itemCount={numItems}
153
- itemData={Array.from({ length: numItems }, (_, i) => i).reverse()}
161
+ itemData={Array.from({ length: numItems }, (_, i) => i).toReversed()}
154
162
  >
155
163
  {ListItem}
156
164
  </ReactWindow.FixedSizeList>
@@ -187,5 +195,83 @@ Vitest.describe.each([{ strictMode: true }, { strictMode: false }] as const)(
187
195
  expect(result.current).toBe(1)
188
196
  }),
189
197
  )
198
+
199
+ Vitest.scopedLive('supports query builders directly', () =>
200
+ Effect.gen(function* () {
201
+ const { wrapper, store } = yield* makeTodoMvcReact({ strictMode })
202
+
203
+ store.commit(
204
+ events.todoCreated({ id: 't1', text: 'buy milk', completed: false }),
205
+ events.todoCreated({ id: 't2', text: 'buy eggs', completed: true }),
206
+ )
207
+
208
+ const todosWhereIncomplete = tables.todos.where({ completed: false })
209
+
210
+ const { result } = ReactTesting.renderHook(() => store.useQuery(todosWhereIncomplete).map((todo) => todo.id), {
211
+ wrapper,
212
+ })
213
+
214
+ expect(result.current).toEqual(['t1'])
215
+ }),
216
+ )
217
+
218
+ Vitest.scopedLive('derives a fresh LiveQuery per Store instance', () =>
219
+ Effect.gen(function* () {
220
+ const makeStore = () =>
221
+ createStore({
222
+ schema,
223
+ storeId: 'reset-test',
224
+ adapter: makeInMemoryAdapter({ clientId: 'stable-client', sessionId: 'stable-session' }),
225
+ debug: { instanceId: 'test' },
226
+ }).pipe(provideOtel({}))
227
+
228
+ const storeA = withReactApi(yield* makeStore())
229
+ const storeB = withReactApi(yield* makeStore())
230
+
231
+ const allTodos$ = queryDb({ query: `select * from todos`, schema: Schema.Array(tables.todos.rowSchema) })
232
+
233
+ const MaybeStrictMode = strictMode === true ? React.StrictMode : React.Fragment
234
+ const wrapper = ({ children }: any) => <MaybeStrictMode>{children}</MaybeStrictMode>
235
+
236
+ storeA.commit(events.todoCreated({ id: 't1', text: 'buy milk', completed: false }))
237
+
238
+ const { result, rerender } = ReactTesting.renderHook(
239
+ ({ store }: { store: typeof storeA }) => store.useQuery(allTodos$).length,
240
+ { wrapper, initialProps: { store: storeA } },
241
+ )
242
+
243
+ expect(result.current).toBe(1)
244
+
245
+ rerender({ store: storeB })
246
+
247
+ expect(result.current).toBe(0)
248
+ }),
249
+ )
250
+
251
+ Vitest.scopedLive('union of different result types with useQuery', () =>
252
+ Effect.gen(function* () {
253
+ const { wrapper, store, renderCount } = yield* makeTodoMvcReact({ strictMode })
254
+
255
+ const str$ = signal('hello', { label: 'str' })
256
+ const num$ = signal(123, { label: 'num' })
257
+
258
+ const { result, rerender } = ReactTesting.renderHook(
259
+ (useNum: boolean) => {
260
+ renderCount.inc()
261
+ const query$ = React.useMemo(() => (useNum === true ? num$ : str$), [useNum])
262
+ return store.useQuery(query$)
263
+ },
264
+ { wrapper, initialProps: false },
265
+ )
266
+
267
+ expect(result.current).toBe('hello')
268
+ expect(renderCount.val).toBe(1)
269
+
270
+ rerender(true)
271
+
272
+ expect(result.current).toBe(123)
273
+ expect(renderCount.val).toBe(2)
274
+ }),
275
+ )
190
276
  },
191
277
  )
package/src/useQuery.ts CHANGED
@@ -1,13 +1,19 @@
1
- import type { LiveQuery, LiveQueryDef, Store } from '@livestore/livestore'
2
- import { extractStackInfoFromStackTrace, stackInfoToString } from '@livestore/livestore'
3
- import type { LiveQueries } from '@livestore/livestore/internal'
4
- import { deepEqual, indent, shouldNeverHappen } from '@livestore/utils'
5
- import * as otel from '@opentelemetry/api'
1
+ import type * as otel from '@opentelemetry/api'
6
2
  import React from 'react'
7
3
 
8
- import { LiveStoreContext } from './LiveStoreContext.ts'
4
+ import {
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'
13
+ import type { LiveQueries } from '@livestore/livestore/internal'
14
+ import { deepEqual, shouldNeverHappen } from '@livestore/utils'
15
+
9
16
  import { useRcResource } from './useRcResource.ts'
10
- import { originalStackLimit } from './utils/stack-info.ts'
11
17
  import { useStateRefWithReactiveInput } from './utils/useStateRefWithReactiveInput.ts'
12
18
 
13
19
  /**
@@ -21,15 +27,36 @@ import { useStateRefWithReactiveInput } from './utils/useStateRefWithReactiveInp
21
27
  * }
22
28
  * ```
23
29
  */
24
- export const useQuery = <TQuery extends LiveQueryDef.Any>(
25
- queryDef: TQuery,
30
+ export const useQuery = <TQueryable extends Queryable<any>>(
31
+ queryable: TQueryable,
26
32
  options?: { store?: Store },
27
- ): LiveQueries.GetResult<TQuery> => useQueryRef(queryDef, options).valueRef.current
33
+ ): Queryable.Result<TQueryable> => useQueryRef(queryable, options).valueRef.current
28
34
 
29
35
  /**
36
+ * Like `useQuery`, but also returns a reference to the underlying LiveQuery instance.
37
+ *
38
+ * Usage
39
+ * - Accepts any `Queryable<TResult>`: a `LiveQueryDef`, `SignalDef`, a `LiveQuery` instance
40
+ * or a SQL `QueryBuilder`. Unions of queryables are supported and the result type is
41
+ * inferred via `Queryable.Result<TQueryable>`.
42
+ * - Creates an OpenTelemetry span per unique query, reusing it while the ref-counted
43
+ * resource is alive. The span name is updated once the dynamic label is known.
44
+ * - Manages a reference-counted resource under-the-hood so query instances are shared
45
+ * across re-renders and properly disposed once no longer referenced.
46
+ *
47
+ * Parameters
48
+ * - `queryable`: The query definition/instance/builder to run and subscribe to.
49
+ * - `options.store`: The store to use. Required when calling `useQueryRef` directly; automatically provided when using `store.useQuery()`.
50
+ * - `options.otelContext`: Optional parent otel context for the query span.
51
+ * - `options.otelSpanName`: Optional explicit span name; otherwise derived from the query label.
52
+ *
53
+ * Returns
54
+ * - `valueRef`: A React ref whose `current` holds the latest query result. The type is
55
+ * `Queryable.Result<TQueryable>` with full inference for unions.
56
+ * - `queryRcRef`: The underlying reference-counted `LiveQuery` instance used by the store.
30
57
  */
31
- export const useQueryRef = <TQuery extends LiveQueryDef.Any>(
32
- queryDef: TQuery,
58
+ export const useQueryRef = <TQueryable extends Queryable<any>>(
59
+ queryable: TQueryable,
33
60
  options?: {
34
61
  store?: Store
35
62
  /** Parent otel context for the query */
@@ -38,102 +65,58 @@ export const useQueryRef = <TQuery extends LiveQueryDef.Any>(
38
65
  otelSpanName?: string
39
66
  },
40
67
  ): {
41
- valueRef: React.RefObject<LiveQueries.GetResult<TQuery>>
42
- queryRcRef: LiveQueries.RcRef<LiveQuery<LiveQueries.GetResult<TQuery>>>
68
+ valueRef: React.RefObject<Queryable.Result<TQueryable>>
69
+ queryRcRef: LiveQueries.RcRef<LiveQuery<Queryable.Result<TQueryable>>>
43
70
  } => {
44
- const store =
45
- options?.store ?? // biome-ignore lint/correctness/useHookAtTopLevel: store is stable
46
- React.useContext(LiveStoreContext)?.store ??
47
- shouldNeverHappen(`No store provided to useQuery`)
48
-
49
- // It's important to use all "aspects" of a store instance here, otherwise we get unexpected cache mappings
50
- const rcRefKey = `${store.storeId}_${store.clientId}_${store.sessionId}_${queryDef.hash}`
71
+ const store = options?.store ?? shouldNeverHappen(`No store provided to useQuery`)
51
72
 
52
- const stackInfo = React.useMemo(() => {
53
- Error.stackTraceLimit = 10
54
- const stack = new Error().stack!
55
- Error.stackTraceLimit = originalStackLimit
56
- return extractStackInfoFromStackTrace(stack)
57
- }, [])
73
+ type TResult = Queryable.Result<TQueryable>
58
74
 
59
- const { queryRcRef, span, otelContext } = useRcResource(
60
- rcRefKey,
61
- () => {
62
- const queryDefLabel = queryDef.label
63
-
64
- const span = store.otel.tracer.startSpan(
65
- options?.otelSpanName ?? `LiveStore:useQuery:${queryDefLabel}`,
66
- { attributes: { label: queryDefLabel, firstStackInfo: JSON.stringify(stackInfo) } },
67
- options?.otelContext ?? store.otel.queriesSpanContext,
68
- )
75
+ const normalized = React.useMemo<NormalizedQueryable<TResult>>(
76
+ () => normalizeQueryable(queryable as Queryable<TResult>),
77
+ [queryable],
78
+ )
69
79
 
70
- const otelContext = otel.trace.setSpan(otel.context.active(), span)
80
+ const rcRefKey = React.useMemo(() => computeRcRefKey(store, normalized), [normalized, store])
71
81
 
72
- const queryRcRef = queryDef.make(store.reactivityGraph.context!, otelContext)
82
+ const stackInfo = React.useMemo(() => captureStackInfo(), [])
73
83
 
74
- return { queryRcRef, span, otelContext }
75
- },
84
+ const { queryRcRef, span, otelContext } = useRcResource(
85
+ store,
86
+ rcRefKey,
87
+ () =>
88
+ createQueryResource(store, normalized, stackInfo, {
89
+ otelSpanName: options?.otelSpanName,
90
+ otelContext: options?.otelContext,
91
+ }),
76
92
  // We need to keep the queryRcRef alive a bit longer, so we have a second `useRcResource` below
77
93
  // which takes care of disposing the queryRcRef
78
94
  () => {},
79
95
  )
80
96
 
81
- // if (queryRcRef.value._tag === 'signal') {
82
- // const queryRcRef.value.get()
83
- // }
84
-
85
- const query$ = queryRcRef.value as LiveQuery<LiveQueries.GetResult<TQuery>>
97
+ const query$ = queryRcRef.value
86
98
 
87
99
  React.useDebugValue(`LiveStore:useQuery:${query$.id}:${query$.label}`)
88
- // console.debug(`LiveStore:useQuery:${query$.id}:${query$.label}`)
89
-
90
- const initialResult = React.useMemo(() => {
91
- try {
92
- return query$.run({
93
- otelContext,
94
- debugRefreshReason: {
95
- _tag: 'react',
96
- api: 'useQuery',
97
- label: `useQuery:initial-run:${query$.label}`,
98
- stackInfo,
99
- },
100
- })
101
- } catch (cause: any) {
102
- console.error('[@livestore/react:useQuery] Error running query', cause)
103
- throw new Error(
104
- `\
105
- [@livestore/react:useQuery] Error running query: ${cause.name}
106
-
107
- Query: ${query$.label}
108
100
 
109
- React trace:
110
-
111
- ${indent(stackInfoToString(stackInfo), 4)}
112
-
113
- Stack trace:
114
- `,
115
- { cause },
116
- )
117
- }
118
- }, [otelContext, query$, stackInfo])
101
+ const initialResult = React.useMemo(
102
+ () => runInitialQuery(query$, otelContext, stackInfo, 'react'),
103
+ [otelContext, query$, stackInfo],
104
+ )
119
105
 
120
106
  // We know the query has a result by the time we use it; so we can synchronously populate a default state
121
- const [valueRef, setValue] = useStateRefWithReactiveInput<LiveQueries.GetResult<TQuery>>(initialResult)
122
-
123
- // TODO we probably need to change the order of `useEffect` calls, so we destroy the query at the end
124
- // before calling the LS `onEffect` on it
107
+ const [valueRef, setValue] = useStateRefWithReactiveInput<TResult>(initialResult)
125
108
 
126
109
  // Subscribe to future updates for this query
127
110
  React.useEffect(() => {
128
- // TODO double check whether we still need `activeSubscriptions`
129
111
  query$.activeSubscriptions.add(stackInfo)
130
112
 
131
113
  // Dynamic queries only set their actual label after they've been run the first time,
132
114
  // so we're also updating the span name here.
133
115
  span.updateName(options?.otelSpanName ?? `LiveStore:useQuery:${query$.label}`)
134
116
 
135
- return store.subscribe(query$, {
136
- onUpdate: (newValue) => {
117
+ return store.subscribe(
118
+ query$,
119
+ (newValue) => {
137
120
  // NOTE: we return a reference to the result object within LiveStore;
138
121
  // this implies that app code must not mutate the results, or else
139
122
  // there may be weird reactivity bugs.
@@ -141,19 +124,21 @@ Stack trace:
141
124
  setValue(newValue)
142
125
  }
143
126
  },
144
- onUnsubsubscribe: () => {
145
- query$.activeSubscriptions.delete(stackInfo)
127
+ {
128
+ onUnsubsubscribe: () => {
129
+ query$.activeSubscriptions.delete(stackInfo)
130
+ },
131
+ label: query$.label,
132
+ otelContext,
146
133
  },
147
- label: query$.label,
148
- otelContext,
149
- })
134
+ )
150
135
  }, [stackInfo, query$, setValue, store, valueRef, otelContext, span, options?.otelSpanName])
151
136
 
152
137
  useRcResource(
138
+ store,
153
139
  rcRefKey,
154
140
  () => ({ queryRcRef, span }),
155
141
  ({ queryRcRef, span }) => {
156
- // console.debug('deref', queryRcRef.value.id, queryRcRef.value.label)
157
142
  queryRcRef.deref()
158
143
  span.end()
159
144
  },
@@ -2,20 +2,23 @@ import * as ReactTesting from '@testing-library/react'
2
2
  import * as React from 'react'
3
3
  import { beforeEach, describe, expect, it, vi } from 'vitest'
4
4
 
5
- import { __resetUseRcResourceCache, useRcResource } from './useRcResource.js'
5
+ import { __resetUseRcResourceCache, useRcResource } from './useRcResource.ts'
6
6
 
7
7
  describe.each([{ strictMode: true }, { strictMode: false }])('useRcResource (strictMode=%s)', ({ strictMode }) => {
8
8
  beforeEach(() => {
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
+ const scope = {}
15
16
  const makeSpy = vi.fn(() => Symbol('statefulResource'))
16
17
  const cleanupSpy = vi.fn()
17
18
 
18
- const { result, unmount } = ReactTesting.renderHook(() => useRcResource('key-1', makeSpy, cleanupSpy), { wrapper })
19
+ const { result, unmount } = ReactTesting.renderHook(() => useRcResource(scope, 'key-1', makeSpy, cleanupSpy), {
20
+ wrapper,
21
+ })
19
22
 
20
23
  expect(makeSpy).toHaveBeenCalledTimes(1)
21
24
  expect(result.current).toBeDefined()
@@ -26,11 +29,12 @@ describe.each([{ strictMode: true }, { strictMode: false }])('useRcResource (str
26
29
  })
27
30
 
28
31
  it('should reuse the same entity when the key remains unchanged', () => {
32
+ const scope = {}
29
33
  const makeSpy = vi.fn(() => Symbol('statefulResource'))
30
34
  const cleanupSpy = vi.fn()
31
35
 
32
36
  const { result, rerender, unmount } = ReactTesting.renderHook(
33
- ({ key }) => useRcResource(key, makeSpy, cleanupSpy),
37
+ ({ key }) => useRcResource(scope, key, makeSpy, cleanupSpy),
34
38
  { initialProps: { key: 'consistent-key' }, wrapper },
35
39
  )
36
40
 
@@ -48,11 +52,12 @@ describe.each([{ strictMode: true }, { strictMode: false }])('useRcResource (str
48
52
  })
49
53
 
50
54
  it('should dispose the previous instance when the key changes', () => {
55
+ const scope = {}
51
56
  const makeSpy = vi.fn(() => Symbol('statefulResource'))
52
57
  const cleanupSpy = vi.fn()
53
58
 
54
59
  const { result, rerender, unmount } = ReactTesting.renderHook(
55
- ({ key }) => useRcResource(key, makeSpy, cleanupSpy),
60
+ ({ key }) => useRcResource(scope, key, makeSpy, cleanupSpy),
56
61
  { initialProps: { key: 'a' }, wrapper },
57
62
  )
58
63
 
@@ -71,18 +76,18 @@ describe.each([{ strictMode: true }, { strictMode: false }])('useRcResource (str
71
76
  })
72
77
 
73
78
  it('should not dispose the entity until all consumers unmount', () => {
79
+ const scope = {}
74
80
  const makeSpy = vi.fn(() => Symbol('statefulResource'))
75
81
  const cleanupSpy = vi.fn()
76
82
 
77
- // Simulate two consumers using the same key independently.
78
- const { unmount: unmount1 } = ReactTesting.renderHook(() => useRcResource('shared-key', makeSpy, cleanupSpy), {
79
- wrapper,
80
- })
83
+ // Simulate two consumers using the same (scope, key) pair independently.
84
+ const { unmount: unmount1 } = ReactTesting.renderHook(
85
+ () => useRcResource(scope, 'shared-key', makeSpy, cleanupSpy),
86
+ { wrapper },
87
+ )
81
88
  const { unmount: unmount2, result } = ReactTesting.renderHook(
82
- () => useRcResource('shared-key', makeSpy, cleanupSpy),
83
- {
84
- wrapper,
85
- },
89
+ () => useRcResource(scope, 'shared-key', makeSpy, cleanupSpy),
90
+ { wrapper },
86
91
  )
87
92
 
88
93
  expect(result.current).toBeDefined()
@@ -98,10 +103,11 @@ describe.each([{ strictMode: true }, { strictMode: false }])('useRcResource (str
98
103
  })
99
104
 
100
105
  it('should handle rapid key changes correctly', () => {
106
+ const scope = {}
101
107
  const makeSpy = vi.fn(() => Symbol('statefulResource'))
102
108
  const cleanupSpy = vi.fn()
103
109
 
104
- const { rerender, unmount } = ReactTesting.renderHook(({ key }) => useRcResource(key, makeSpy, cleanupSpy), {
110
+ const { rerender, unmount } = ReactTesting.renderHook(({ key }) => useRcResource(scope, key, makeSpy, cleanupSpy), {
105
111
  initialProps: { key: '1' },
106
112
  wrapper,
107
113
  })
@@ -119,49 +125,99 @@ describe.each([{ strictMode: true }, { strictMode: false }])('useRcResource (str
119
125
  // Unmounting the final consumer disposes the key '3' instance.
120
126
  expect(cleanupSpy).toHaveBeenCalledTimes(3)
121
127
  })
122
- })
123
128
 
124
- // This code was useful to better understand the hook behaviour with and without strict mode
125
- // describe('debug', () => {
126
- // const useStrictTest = (key: string) => {
127
- // const id = React.useId()
128
- // console.log(key, 'id', id)
129
-
130
- // const x = React.useMemo(() => {
131
- // console.log('useMemo', key)
132
- // return 'hi' + key
133
- // }, [key])
134
-
135
- // React.useEffect(() => {
136
- // console.log('useEffect', key)
137
- // return () => {
138
- // console.log('unmount', key)
139
- // }
140
- // }, [])
141
-
142
- // return x
143
- // }
144
-
145
- // it('strict mode component', () => {
146
- // console.log('strict mode component')
147
- // const Root = () => {
148
- // useStrictTest('a')
149
- // return null
150
- // }
151
- // const { unmount } = ReactTesting.render(
152
- // <React.StrictMode>
153
- // <Root />
154
- // </React.StrictMode>,
155
- // )
156
-
157
- // unmount()
158
- // })
159
-
160
- // it('strict mode hook', () => {
161
- // console.log('strict mode hook')
162
- // const wrapper: React.FC<{ children: React.ReactNode }> = React.StrictMode
163
- // const { unmount } = ReactTesting.renderHook(() => useStrictTest('b'), { wrapper })
164
-
165
- // unmount()
166
- // })
167
- // })
129
+ it('should isolate entities created with the same key but different scopes', () => {
130
+ const scopeA = { tag: 'A' }
131
+ const scopeB = { tag: 'B' }
132
+ const makeSpy = vi.fn(() => Symbol('statefulResource'))
133
+ const cleanupSpy = vi.fn()
134
+
135
+ const { result: resultA } = ReactTesting.renderHook(
136
+ () => useRcResource(scopeA, 'shared-key', makeSpy, cleanupSpy),
137
+ { wrapper },
138
+ )
139
+ const { result: resultB } = ReactTesting.renderHook(
140
+ () => useRcResource(scopeB, 'shared-key', makeSpy, cleanupSpy),
141
+ { wrapper },
142
+ )
143
+
144
+ expect(resultA.current).not.toBe(resultB.current)
145
+ expect(makeSpy).toHaveBeenCalledTimes(2)
146
+ })
147
+
148
+ it('should dispose the previous entity when the scope changes (key unchanged)', () => {
149
+ const scopeA = { tag: 'A' }
150
+ const scopeB = { tag: 'B' }
151
+ const makeSpy = vi.fn(() => Symbol('statefulResource'))
152
+ const cleanupSpy = vi.fn()
153
+
154
+ const { result, rerender, unmount } = ReactTesting.renderHook(
155
+ ({ scope }) => useRcResource(scope, 'k', makeSpy, cleanupSpy),
156
+ { initialProps: { scope: scopeA }, wrapper },
157
+ )
158
+
159
+ const instanceA = result.current
160
+ expect(makeSpy).toHaveBeenCalledTimes(1)
161
+
162
+ rerender({ scope: scopeB })
163
+ const instanceB = result.current
164
+
165
+ expect(instanceA).not.toBe(instanceB)
166
+ expect(makeSpy).toHaveBeenCalledTimes(2)
167
+ // The scopeA entry's last consumer left when we switched scopes → cleaned up.
168
+ expect(cleanupSpy).toHaveBeenCalledTimes(1)
169
+
170
+ unmount()
171
+ expect(cleanupSpy).toHaveBeenCalledTimes(2)
172
+ })
173
+
174
+ it('should not reuse a cached entity after the scope is replaced', () => {
175
+ const makeSpy = vi.fn(() => Symbol('statefulResource'))
176
+ const cleanupSpy = vi.fn()
177
+
178
+ const scope1 = {}
179
+ const { result: result1, unmount: unmount1 } = ReactTesting.renderHook(
180
+ () => useRcResource(scope1, 'k', makeSpy, cleanupSpy),
181
+ { wrapper },
182
+ )
183
+ const instance1 = result1.current
184
+ unmount1()
185
+ expect(cleanupSpy).toHaveBeenCalledTimes(1)
186
+
187
+ // Fresh scope, same string key — must NOT reuse the (already-disposed) entry.
188
+ const scope2 = {}
189
+ const { result: result2, unmount: unmount2 } = ReactTesting.renderHook(
190
+ () => useRcResource(scope2, 'k', makeSpy, cleanupSpy),
191
+ { wrapper },
192
+ )
193
+
194
+ expect(result2.current).not.toBe(instance1)
195
+ expect(makeSpy).toHaveBeenCalledTimes(2)
196
+
197
+ unmount2()
198
+ expect(cleanupSpy).toHaveBeenCalledTimes(2)
199
+ })
200
+
201
+ it('should share the entity across components within the same scope', () => {
202
+ const scope = {}
203
+ const makeSpy = vi.fn(() => Symbol('statefulResource'))
204
+ const cleanupSpy = vi.fn()
205
+
206
+ const { result: r1, unmount: unmount1 } = ReactTesting.renderHook(
207
+ () => useRcResource(scope, 'k', makeSpy, cleanupSpy),
208
+ { wrapper },
209
+ )
210
+ const { result: r2, unmount: unmount2 } = ReactTesting.renderHook(
211
+ () => useRcResource(scope, 'k', makeSpy, cleanupSpy),
212
+ { wrapper },
213
+ )
214
+
215
+ expect(r1.current).toBe(r2.current)
216
+ expect(makeSpy).toHaveBeenCalledTimes(1)
217
+
218
+ unmount1()
219
+ expect(cleanupSpy).not.toHaveBeenCalled()
220
+ unmount2()
221
+ expect(cleanupSpy).toHaveBeenCalledTimes(1)
222
+ })
223
+ })