@livestore/react 0.4.0-dev.25 → 0.4.0-dev.27

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.
@@ -12,10 +12,13 @@ describe.each([{ strictMode: true }, { strictMode: false }])('useRcResource (str
12
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
+ })
@@ -25,10 +25,10 @@ import * as React from 'react'
25
25
  * - Upon component unmount, the reference count is decremented, leading to disposal (via the `dispose` function)
26
26
  * if the reference count drops to zero. An unmount is either detected via React's `useEffect` callback or
27
27
  * in the useMemo hook when the key changes.
28
- *
28
+ *
29
29
  * Why this is needed in LiveStore:
30
30
  * Let's first take a look at the "trivial implementation":
31
- *
31
+ *
32
32
  * ```ts
33
33
  * const useSimpleResource = <T>(create: () => T, dispose: (resource: T) => void) => {
34
34
  * const val = React.useMemo(() => create(), [create])
@@ -42,7 +42,7 @@ import * as React from 'react'
42
42
  * return val
43
43
  * }
44
44
  * ```
45
- *
45
+ *
46
46
  * LiveStore uses this hook to create LiveQuery instances which are stateful and must not be leaked.
47
47
  * The simple implementation above would leak the LiveQuery instance if the component is unmounted or props change.
48
48
  *
@@ -72,12 +72,14 @@ import * as React from 'react'
72
72
  * @returns The stateful entity corresponding to the provided key.
73
73
  */
74
74
  export const useRcResource = <T>(
75
+ scope: object,
75
76
  key: string,
76
77
  create: () => T,
77
78
  dispose: (resource: NoInfer<T>) => void,
78
79
  _options?: { debugPrint?: (resource: NoInfer<T>) => ReadonlyArray<any> },
79
80
  ): T => {
80
81
  const keyRef = React.useRef<string | undefined>(undefined)
82
+ const scopeRef = React.useRef<object | undefined>(undefined)
81
83
  const didDisposeInMemo = React.useRef(false)
82
84
  const createRef = React.useRef(create)
83
85
  const disposeRef = React.useRef(dispose)
@@ -85,79 +87,72 @@ export const useRcResource = <T>(
85
87
  createRef.current = create
86
88
  disposeRef.current = dispose
87
89
 
88
- // biome-ignore lint/correctness/useExhaustiveDependencies: Dependency is deliberately limited to `key` to avoid unintended re-creations.
90
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Dependencies are deliberately limited to `scope` and `key` to avoid unintended re-creations.
89
91
  const resource = React.useMemo(() => {
90
- // console.debug('useMemo', key)
92
+ const bucket = getBucket(scope)
93
+
91
94
  if (didDisposeInMemo.current === true) {
92
- // console.debug('useMemo', key, 'skip')
93
- const cachedItem = cache.get(key)
95
+ const cachedItem = bucket.get(key)
94
96
  if (cachedItem !== undefined && cachedItem._tag === 'active') {
95
97
  return cachedItem.resource
96
98
  }
97
99
  }
98
100
 
99
- // Check if the key has changed (or is undefined)
100
- if (keyRef.current !== undefined && keyRef.current !== key) {
101
- // If the key has changed, decrement the reference on the previous key
101
+ // Check if the (scope, key) pair has changed (or is undefined)
102
+ if (keyRef.current !== undefined && (keyRef.current !== key || scopeRef.current !== scope)) {
102
103
  const previousKey = keyRef.current
103
- const cachedItemForPreviousKey = cache.get(previousKey)
104
+ // scopeRef.current is set together with keyRef.current below, so it's defined here.
105
+ const previousBucket = getBucket(scopeRef.current!)
106
+ const cachedItemForPreviousKey = previousBucket.get(previousKey)
104
107
  if (cachedItemForPreviousKey !== undefined && cachedItemForPreviousKey._tag === 'active') {
105
- // previousKeyRef.current = previousKey
106
108
  cachedItemForPreviousKey.rc--
107
109
 
108
- // console.debug('useMemo', key, 'rc--', previousKey, cachedItemForPreviousKey.rc)
109
-
110
110
  if (cachedItemForPreviousKey.rc === 0) {
111
111
  // Clean up the stateful resource if no longer referenced
112
112
  disposeRef.current(cachedItemForPreviousKey.resource)
113
- cache.set(previousKey, { _tag: 'destroyed' })
113
+ previousBucket.set(previousKey, { _tag: 'destroyed' })
114
114
  didDisposeInMemo.current = true
115
115
  }
116
116
  }
117
117
  }
118
118
 
119
- const cachedItem = cache.get(key)
119
+ const cachedItem = bucket.get(key)
120
120
  if (cachedItem !== undefined && cachedItem._tag === 'active') {
121
121
  // In React Strict Mode, the `useMemo` hook is called multiple times,
122
122
  // so we only increment the reference from the first call for this component.
123
123
  cachedItem.rc++
124
- // console.debug('rc++', cachedItem.rc, ...(_options?.debugPrint?.(cachedItem.resource) ?? []))
125
-
126
124
  return cachedItem.resource
127
125
  }
128
126
 
129
127
  // Create a new stateful resource if not cached
130
128
  const resource = createRef.current()
131
- cache.set(key, { _tag: 'active', rc: 1, resource })
129
+ bucket.set(key, { _tag: 'active', rc: 1, resource })
132
130
  return resource
133
- }, [key])
131
+ }, [scope, key])
134
132
 
135
133
  // biome-ignore lint/correctness/useExhaustiveDependencies: We assume the `dispose` function is stable and won't change across renders
136
134
  React.useEffect(() => {
137
135
  return () => {
138
136
  if (didDisposeInMemo.current === true) {
139
- // console.debug('unmount', keyRef.current, 'skip')
140
137
  didDisposeInMemo.current = false
141
138
  return
142
139
  }
143
140
 
144
- // console.debug('unmount', keyRef.current)
145
- const cachedItem = cache.get(key)
146
- // If the stateful resource is already cleaned up, do nothing.
141
+ const bucket = getBucket(scope)
142
+ const cachedItem = bucket.get(key)
147
143
  if (cachedItem === undefined || cachedItem._tag === 'destroyed') return
148
144
 
149
145
  cachedItem.rc--
150
146
 
151
- // console.debug('rc--', cachedItem.rc, ...(_options?.debugPrint?.(cachedItem.resource) ?? []))
152
-
153
147
  if (cachedItem.rc === 0) {
154
148
  disposeRef.current(cachedItem.resource)
155
- cache.delete(key)
149
+ bucket.delete(key)
156
150
  }
157
151
  }
158
- }, [key])
152
+ }, [scope, key])
159
153
 
160
154
  keyRef.current = key
155
+ scopeRef.current = scope
161
156
 
162
157
  return resource
163
158
  }
@@ -166,8 +161,7 @@ export const useRcResource = <T>(
166
161
  // we are using this cache to avoid starting multiple queries/spans for the same component.
167
162
  // This is somewhat against some recommended React best practices, but it should be fine in our case below.
168
163
  // Please definitely open an issue if you see or run into any problems with this approach!
169
- const cache = new Map<
170
- string,
164
+ type Entry =
171
165
  | {
172
166
  _tag: 'active'
173
167
  rc: number
@@ -176,8 +170,23 @@ const cache = new Map<
176
170
  | {
177
171
  _tag: 'destroyed'
178
172
  }
179
- >()
173
+
174
+ type Bucket = Map<string, Entry>
175
+
176
+ // Per-scope buckets. Keying by the scope object (e.g. a Store instance) ensures that
177
+ // when the scope is replaced (store dispose/recreate), the new scope gets a fresh bucket
178
+ // and stale entries from the disposed scope become GC-eligible. See issue #1186.
179
+ let scopedBuckets = new WeakMap<object, Bucket>()
180
+
181
+ const getBucket = (scope: object): Bucket => {
182
+ let bucket = scopedBuckets.get(scope)
183
+ if (bucket === undefined) {
184
+ bucket = new Map()
185
+ scopedBuckets.set(scope, bucket)
186
+ }
187
+ return bucket
188
+ }
180
189
 
181
190
  export const __resetUseRcResourceCache = () => {
182
- cache.clear()
191
+ scopedBuckets = new WeakMap()
183
192
  }