@zeix/cause-effect 0.17.1 → 0.17.3

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 (57) hide show
  1. package/.ai-context.md +13 -0
  2. package/.github/copilot-instructions.md +4 -0
  3. package/.zed/settings.json +3 -0
  4. package/CLAUDE.md +41 -7
  5. package/README.md +48 -25
  6. package/archive/benchmark.ts +0 -5
  7. package/archive/collection.ts +6 -65
  8. package/archive/composite.ts +85 -0
  9. package/archive/computed.ts +18 -20
  10. package/archive/list.ts +7 -75
  11. package/archive/memo.ts +15 -15
  12. package/archive/state.ts +2 -1
  13. package/archive/store.ts +8 -78
  14. package/archive/task.ts +20 -25
  15. package/index.dev.js +508 -526
  16. package/index.js +1 -1
  17. package/index.ts +9 -11
  18. package/package.json +6 -6
  19. package/src/classes/collection.ts +70 -107
  20. package/src/classes/computed.ts +165 -149
  21. package/src/classes/list.ts +145 -107
  22. package/src/classes/ref.ts +19 -17
  23. package/src/classes/state.ts +21 -17
  24. package/src/classes/store.ts +125 -73
  25. package/src/diff.ts +2 -1
  26. package/src/effect.ts +17 -10
  27. package/src/errors.ts +14 -1
  28. package/src/resolve.ts +1 -1
  29. package/src/signal.ts +3 -2
  30. package/src/system.ts +159 -61
  31. package/src/util.ts +0 -6
  32. package/test/batch.test.ts +4 -11
  33. package/test/benchmark.test.ts +4 -2
  34. package/test/collection.test.ts +106 -107
  35. package/test/computed.test.ts +351 -112
  36. package/test/effect.test.ts +2 -2
  37. package/test/list.test.ts +62 -102
  38. package/test/ref.test.ts +128 -2
  39. package/test/state.test.ts +16 -22
  40. package/test/store.test.ts +101 -108
  41. package/test/util/dependency-graph.ts +2 -2
  42. package/tsconfig.build.json +11 -0
  43. package/tsconfig.json +5 -7
  44. package/types/index.d.ts +3 -3
  45. package/types/src/classes/collection.d.ts +9 -10
  46. package/types/src/classes/computed.d.ts +17 -20
  47. package/types/src/classes/list.d.ts +8 -6
  48. package/types/src/classes/ref.d.ts +8 -12
  49. package/types/src/classes/state.d.ts +5 -8
  50. package/types/src/classes/store.d.ts +14 -13
  51. package/types/src/effect.d.ts +1 -2
  52. package/types/src/errors.d.ts +2 -1
  53. package/types/src/signal.d.ts +3 -2
  54. package/types/src/system.d.ts +47 -34
  55. package/types/src/util.d.ts +1 -2
  56. package/src/classes/composite.ts +0 -176
  57. package/types/src/classes/composite.d.ts +0 -15
package/test/list.test.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import {
3
3
  createEffect,
4
- List,
5
4
  createStore,
6
5
  isCollection,
7
6
  isList,
8
7
  isStore,
8
+ List,
9
9
  Memo,
10
10
  State,
11
11
  UNSET,
@@ -130,17 +130,6 @@ describe('list', () => {
130
130
  expect(names.get()).toEqual(['Alice', 'Bob', 'Charlie'])
131
131
  })
132
132
 
133
- test('emits sort notification with new order', () => {
134
- const numbers = new List([3, 1, 2])
135
- let sortNotification: readonly string[] = []
136
- numbers.on('sort', sort => {
137
- sortNotification = sort
138
- })
139
- numbers.sort()
140
- expect(sortNotification).toHaveLength(3)
141
- expect(sortNotification).toEqual(['1', '2', '0'])
142
- })
143
-
144
133
  test('sort is reactive - watchers are notified', () => {
145
134
  const numbers = new List([3, 1, 2])
146
135
  let effectCount = 0
@@ -302,47 +291,6 @@ describe('list', () => {
302
291
  })
303
292
  })
304
293
 
305
- describe('change notifications', () => {
306
- test('emits add notifications', () => {
307
- const numbers = new List([1, 2])
308
- let arrayAddNotification: readonly string[] = []
309
- let newArray: number[] = []
310
- numbers.on('add', add => {
311
- arrayAddNotification = add
312
- newArray = numbers.get()
313
- })
314
- numbers.add(3)
315
- expect(arrayAddNotification).toHaveLength(1)
316
- expect(newArray).toEqual([1, 2, 3])
317
- })
318
-
319
- test('emits change notifications when properties are modified', () => {
320
- const items = new List([{ value: 10 }])
321
- let arrayChangeNotification: readonly string[] = []
322
- let newArray: { value: number }[] = []
323
- items.on('change', change => {
324
- arrayChangeNotification = change
325
- newArray = items.get()
326
- })
327
- items.at(0)?.set({ value: 20 })
328
- expect(arrayChangeNotification).toHaveLength(1)
329
- expect(newArray).toEqual([{ value: 20 }])
330
- })
331
-
332
- test('emits remove notifications when items are removed', () => {
333
- const items = new List([1, 2, 3])
334
- let arrayRemoveNotification: readonly string[] = []
335
- let newArray: number[] = []
336
- items.on('remove', remove => {
337
- arrayRemoveNotification = remove
338
- newArray = items.get()
339
- })
340
- items.remove(1)
341
- expect(arrayRemoveNotification).toHaveLength(1)
342
- expect(newArray).toEqual([1, 3])
343
- })
344
- })
345
-
346
294
  describe('edge cases', () => {
347
295
  test('handles empty lists correctly', () => {
348
296
  const empty = new List([])
@@ -482,7 +430,10 @@ describe('list', () => {
482
430
  ])
483
431
 
484
432
  const enrichedUsers = users.deriveCollection(
485
- async (user, abort: AbortSignal) => {
433
+ async (
434
+ user: { id: number; name: string },
435
+ abort: AbortSignal,
436
+ ) => {
486
437
  // Simulate API call
487
438
  await new Promise(resolve => setTimeout(resolve, 10))
488
439
  if (abort.aborted) throw new Error('Aborted')
@@ -663,54 +614,6 @@ describe('list', () => {
663
614
  })
664
615
  })
665
616
 
666
- describe('collection event handling', () => {
667
- test('emits add events when source adds items', () => {
668
- const numbers = new List([1, 2])
669
- const doubled = numbers.deriveCollection(
670
- (value: number) => value * 2,
671
- )
672
-
673
- let addedKeys: readonly string[] = []
674
- doubled.on('add', keys => {
675
- addedKeys = keys
676
- })
677
-
678
- numbers.add(3)
679
- expect(addedKeys).toHaveLength(1)
680
- })
681
-
682
- test('emits remove events when source removes items', () => {
683
- const numbers = new List([1, 2, 3])
684
- const doubled = numbers.deriveCollection(
685
- (value: number) => value * 2,
686
- )
687
-
688
- let removedKeys: readonly string[] = []
689
- doubled.on('remove', keys => {
690
- removedKeys = keys
691
- })
692
-
693
- numbers.remove(1)
694
- expect(removedKeys).toHaveLength(1)
695
- })
696
-
697
- test('emits sort events when source is sorted', () => {
698
- const numbers = new List([3, 1, 2])
699
- const doubled = numbers.deriveCollection(
700
- (value: number) => value * 2,
701
- )
702
-
703
- let sortedKeys: readonly string[] = []
704
- doubled.on('sort', keys => {
705
- sortedKeys = keys
706
- })
707
-
708
- numbers.sort()
709
- expect(sortedKeys).toHaveLength(3)
710
- expect(doubled.get()).toEqual([2, 4, 6]) // Sorted and doubled
711
- })
712
- })
713
-
714
617
  describe('edge cases', () => {
715
618
  test('handles empty list derivation', () => {
716
619
  const empty = new List<number>([])
@@ -751,4 +654,61 @@ describe('list', () => {
751
654
  })
752
655
  })
753
656
  })
657
+
658
+ describe('Watch Callbacks', () => {
659
+ test('List watched callback is called when effect accesses list.get()', () => {
660
+ let linkWatchedCalled = false
661
+ let listUnwatchedCalled = false
662
+ const numbers = new List([10, 20, 30], {
663
+ watched: () => {
664
+ linkWatchedCalled = true
665
+ },
666
+ unwatched: () => {
667
+ listUnwatchedCalled = true
668
+ },
669
+ })
670
+
671
+ expect(linkWatchedCalled).toBe(false)
672
+
673
+ // Access list via list.get() - this should trigger list's watched callback
674
+ let effectValue: number[] = []
675
+ const cleanup = createEffect(() => {
676
+ effectValue = numbers.get()
677
+ })
678
+
679
+ expect(linkWatchedCalled).toBe(true)
680
+ expect(effectValue).toEqual([10, 20, 30])
681
+ expect(listUnwatchedCalled).toBe(false)
682
+
683
+ // Cleanup effect - should trigger unwatch
684
+ cleanup()
685
+ expect(listUnwatchedCalled).toBe(true)
686
+ })
687
+
688
+ test('List length access triggers List watched callback', () => {
689
+ let listWatchedCalled = false
690
+ let listUnwatchedCalled = false
691
+ const numbers = new List([1, 2, 3], {
692
+ watched: () => {
693
+ listWatchedCalled = true
694
+ },
695
+ unwatched: () => {
696
+ listUnwatchedCalled = true
697
+ },
698
+ })
699
+
700
+ // Access via list.length - this should trigger list's watched callback
701
+ let effectValue: number = 0
702
+ const cleanup = createEffect(() => {
703
+ effectValue = numbers.length
704
+ })
705
+
706
+ expect(listWatchedCalled).toBe(true)
707
+ expect(effectValue).toBe(3)
708
+ expect(listUnwatchedCalled).toBe(false)
709
+
710
+ cleanup()
711
+ expect(listUnwatchedCalled).toBe(true)
712
+ })
713
+ })
754
714
  })
package/test/ref.test.ts CHANGED
@@ -34,8 +34,8 @@ test('Ref - validation with guard function', () => {
34
34
  const validConfig = { host: 'localhost', port: 3000 }
35
35
  const invalidConfig = { host: 'localhost' } // missing port
36
36
 
37
- expect(() => new Ref(validConfig, isConfig)).not.toThrow()
38
- expect(() => new Ref(invalidConfig, isConfig)).toThrow()
37
+ expect(() => new Ref(validConfig, { guard: isConfig })).not.toThrow()
38
+ expect(() => new Ref(invalidConfig, { guard: isConfig })).toThrow()
39
39
  })
40
40
 
41
41
  test('Ref - reactive subscriptions', () => {
@@ -225,3 +225,129 @@ test('Ref - handles complex nested objects', () => {
225
225
 
226
226
  expect(userCount).toBe(2)
227
227
  })
228
+
229
+ test('Ref - options.watched lazy resource management', async () => {
230
+ // 1. Create Ref with current Date
231
+ let counter = 0
232
+ let intervalId: Timer | undefined
233
+ const ref = new Ref(new Date(), {
234
+ watched: () => {
235
+ intervalId = setInterval(() => {
236
+ counter++
237
+ }, 10)
238
+ },
239
+ unwatched: () => {
240
+ if (intervalId) {
241
+ clearInterval(intervalId)
242
+ intervalId = undefined
243
+ }
244
+ },
245
+ })
246
+
247
+ // 2. Counter should not be running yet
248
+ expect(counter).toBe(0)
249
+
250
+ // Wait a bit to ensure counter doesn't increment
251
+ await new Promise(resolve => setTimeout(resolve, 50))
252
+ expect(counter).toBe(0)
253
+ expect(intervalId).toBeUndefined()
254
+
255
+ // 3. Effect subscribes by .get()ting the signal value
256
+ const effectCleanup = createEffect(() => {
257
+ ref.get()
258
+ })
259
+
260
+ // 4. Counter should now be running
261
+ await new Promise(resolve => setTimeout(resolve, 50))
262
+ expect(counter).toBeGreaterThan(0)
263
+ expect(intervalId).toBeDefined()
264
+
265
+ // 5. Call effect cleanup, which should stop internal watcher and unsubscribe
266
+ effectCleanup()
267
+ const counterAfterStop = counter
268
+
269
+ // 6. Ref signal should call #unwatch() and counter should stop incrementing
270
+ await new Promise(resolve => setTimeout(resolve, 50))
271
+ expect(counter).toBe(counterAfterStop) // Counter should not have incremented
272
+ expect(intervalId).toBeUndefined() // Interval should be cleared
273
+ })
274
+
275
+ test('Ref - options.watched exception handling', async () => {
276
+ const ref = new Ref(
277
+ { test: 'value' },
278
+ {
279
+ watched: () => {
280
+ throwingCallbackCalled = true
281
+ throw new Error('Test error in watched callback')
282
+ },
283
+ },
284
+ )
285
+
286
+ // Mock console.error to capture error logs
287
+ const originalError = console.error
288
+ const errorSpy = mock(() => {})
289
+ console.error = errorSpy
290
+
291
+ let throwingCallbackCalled = false
292
+
293
+ // Subscribe to trigger watched callback
294
+ const effectCleanup = createEffect(() => {
295
+ ref.get()
296
+ })
297
+
298
+ // Both callbacks should have been called despite the exception
299
+ expect(throwingCallbackCalled).toBe(true)
300
+
301
+ // Error should have been logged
302
+ expect(errorSpy).toHaveBeenCalledWith(
303
+ 'Error in effect callback:',
304
+ expect.any(Error),
305
+ )
306
+
307
+ // Cleanup
308
+ effectCleanup()
309
+ console.error = originalError
310
+ })
311
+
312
+ test('Ref - options.unwatched exception handling', async () => {
313
+ const ref = new Ref(
314
+ { test: 'value' },
315
+ {
316
+ watched: () => {},
317
+ unwatched: () => {
318
+ cleanup1Called = true
319
+ throw new Error('Test error in cleanup function')
320
+ },
321
+ },
322
+ )
323
+
324
+ // Mock console.error to capture error logs
325
+ const originalError = console.error
326
+ const errorSpy = mock(() => {})
327
+ console.error = errorSpy
328
+
329
+ let cleanup1Called = false
330
+
331
+ // Subscribe and then unsubscribe to trigger cleanup
332
+ const effectCleanup = createEffect(() => {
333
+ ref.get()
334
+ })
335
+
336
+ // Unsubscribe to trigger cleanup functions
337
+ effectCleanup()
338
+
339
+ // Wait a bit for cleanup to complete
340
+ await new Promise(resolve => setTimeout(resolve, 10))
341
+
342
+ // Both cleanup functions should have been called despite the exception
343
+ expect(cleanup1Called).toBe(true)
344
+
345
+ // Error should have been logged
346
+ expect(errorSpy).toHaveBeenCalledWith(
347
+ 'Error in effect cleanup:',
348
+ expect.any(Error),
349
+ )
350
+
351
+ // Cleanup
352
+ console.error = originalError
353
+ })
@@ -125,12 +125,12 @@ describe('State', () => {
125
125
  expect(() => {
126
126
  // @ts-expect-error - Testing invalid input
127
127
  new State(null)
128
- }).toThrow('Nullish signal values are not allowed in state')
128
+ }).toThrow('Nullish signal values are not allowed in State')
129
129
 
130
130
  expect(() => {
131
131
  // @ts-expect-error - Testing invalid input
132
132
  new State(undefined)
133
- }).toThrow('Nullish signal values are not allowed in state')
133
+ }).toThrow('Nullish signal values are not allowed in State')
134
134
  })
135
135
 
136
136
  test('should throw NullishSignalValueError when newValue is nullish in set()', () => {
@@ -139,12 +139,12 @@ describe('State', () => {
139
139
  expect(() => {
140
140
  // @ts-expect-error - Testing invalid input
141
141
  state.set(null)
142
- }).toThrow('Nullish signal values are not allowed in state')
142
+ }).toThrow('Nullish signal values are not allowed in State')
143
143
 
144
144
  expect(() => {
145
145
  // @ts-expect-error - Testing invalid input
146
146
  state.set(undefined)
147
- }).toThrow('Nullish signal values are not allowed in state')
147
+ }).toThrow('Nullish signal values are not allowed in State')
148
148
  })
149
149
 
150
150
  test('should throw specific error types for nullish values', () => {
@@ -154,10 +154,8 @@ describe('State', () => {
154
154
  expect(true).toBe(false) // Should not reach here
155
155
  } catch (error) {
156
156
  expect(error).toBeInstanceOf(TypeError)
157
- expect(error.name).toBe('NullishSignalValueError')
158
- expect(error.message).toBe(
159
- 'Nullish signal values are not allowed in state',
160
- )
157
+ expect((error as Error).name).toBe('NullishSignalValueError')
158
+ expect((error as Error).message).toBe('Nullish signal values are not allowed in State')
161
159
  }
162
160
 
163
161
  const state = new State(42)
@@ -167,10 +165,8 @@ describe('State', () => {
167
165
  expect(true).toBe(false) // Should not reach here
168
166
  } catch (error) {
169
167
  expect(error).toBeInstanceOf(TypeError)
170
- expect(error.name).toBe('NullishSignalValueError')
171
- expect(error.message).toBe(
172
- 'Nullish signal values are not allowed in state',
173
- )
168
+ expect((error as Error).name).toBe('NullishSignalValueError')
169
+ expect((error as Error).message).toBe('Nullish signal values are not allowed in State')
174
170
  }
175
171
  })
176
172
 
@@ -213,22 +209,22 @@ describe('State', () => {
213
209
  expect(() => {
214
210
  // @ts-expect-error - Testing invalid input
215
211
  state.update(null)
216
- }).toThrow('Invalid state update callback null')
212
+ }).toThrow('Invalid State update callback null')
217
213
 
218
214
  expect(() => {
219
215
  // @ts-expect-error - Testing invalid input
220
216
  state.update(undefined)
221
- }).toThrow('Invalid state update callback undefined')
217
+ }).toThrow('Invalid State update callback undefined')
222
218
 
223
219
  expect(() => {
224
220
  // @ts-expect-error - Testing invalid input
225
221
  state.update('not a function')
226
- }).toThrow('Invalid state update callback "not a function"')
222
+ }).toThrow('Invalid State update callback "not a function"')
227
223
 
228
224
  expect(() => {
229
225
  // @ts-expect-error - Testing invalid input
230
226
  state.update(42)
231
- }).toThrow('Invalid state update callback 42')
227
+ }).toThrow('Invalid State update callback 42')
232
228
  })
233
229
 
234
230
  test('should throw specific error type for non-function updater', () => {
@@ -240,10 +236,8 @@ describe('State', () => {
240
236
  expect(true).toBe(false) // Should not reach here
241
237
  } catch (error) {
242
238
  expect(error).toBeInstanceOf(TypeError)
243
- expect(error.name).toBe('InvalidCallbackError')
244
- expect(error.message).toBe(
245
- 'Invalid state update callback null',
246
- )
239
+ expect((error as Error).name).toBe('InvalidCallbackError')
240
+ expect((error as Error).message).toBe('Invalid State update callback null')
247
241
  }
248
242
  })
249
243
 
@@ -266,12 +260,12 @@ describe('State', () => {
266
260
  expect(() => {
267
261
  // @ts-expect-error - Testing invalid return value
268
262
  state.update(() => null)
269
- }).toThrow('Nullish signal values are not allowed in state')
263
+ }).toThrow('Nullish signal values are not allowed in State')
270
264
 
271
265
  expect(() => {
272
266
  // @ts-expect-error - Testing invalid return value
273
267
  state.update(() => undefined)
274
- }).toThrow('Nullish signal values are not allowed in state')
268
+ }).toThrow('Nullish signal values are not allowed in State')
275
269
 
276
270
  // State should remain unchanged after error
277
271
  expect(state.get()).toBe(42)
@@ -225,114 +225,6 @@ describe('store', () => {
225
225
  })
226
226
  })
227
227
 
228
- describe('change tracking and notifications', () => {
229
- test('emits add notifications', () => {
230
- let addNotification: readonly string[] = []
231
- const user = createStore<{ name: string; email?: string }>({
232
- name: 'John',
233
- })
234
- user.on('add', add => {
235
- addNotification = add
236
- })
237
- user.add('email', 'john@example.com')
238
- expect(addNotification).toContain('email')
239
- })
240
-
241
- test('emits change notifications when properties are modified', () => {
242
- const user = createStore({ name: 'John' })
243
- let changeNotification: readonly string[] = []
244
- user.on('change', change => {
245
- changeNotification = change
246
- })
247
- user.name.set('Jane')
248
- expect(changeNotification).toContain('name')
249
- })
250
-
251
- test('emits change notifications for nested property changes', () => {
252
- const user = createStore({
253
- preferences: {
254
- theme: 'light',
255
- },
256
- })
257
- let changeNotification: readonly string[] = []
258
- user.on('change', change => {
259
- changeNotification = change
260
- })
261
- user.preferences.theme.set('dark')
262
- expect(changeNotification).toContain('preferences')
263
- })
264
-
265
- test('emits remove notifications when properties are removed', () => {
266
- const user = createStore({
267
- name: 'John',
268
- email: 'john@example.com',
269
- })
270
- let removeNotification: readonly string[] = []
271
- user.on('remove', remove => {
272
- removeNotification = remove
273
- })
274
- user.remove('email')
275
- expect(removeNotification).toContain('email')
276
- })
277
-
278
- test('set() correctly handles mixed changes, additions, and removals', () => {
279
- const user = createStore<{
280
- name: string
281
- email?: string
282
- preferences: { theme?: string }
283
- age?: number
284
- }>({
285
- name: 'John',
286
- email: 'john@example.com',
287
- preferences: {
288
- theme: 'light',
289
- },
290
- })
291
-
292
- let changeNotification: readonly string[] = []
293
- let addNotification: readonly string[] = []
294
- let removeNotification: readonly string[] = []
295
-
296
- user.on('change', change => {
297
- changeNotification = change
298
- })
299
- user.on('add', add => {
300
- addNotification = add
301
- })
302
- user.on('remove', remove => {
303
- removeNotification = remove
304
- })
305
-
306
- user.set({
307
- name: 'Jane',
308
- preferences: {
309
- theme: 'dark',
310
- },
311
- age: 30,
312
- })
313
-
314
- expect(changeNotification).toContain('name')
315
- expect(changeNotification).toContain('preferences')
316
- expect(addNotification).toContain('age')
317
- expect(removeNotification).toContain('email')
318
- })
319
-
320
- test('notification listeners can be removed', () => {
321
- const user = createStore({ name: 'John' })
322
- let notificationCount = 0
323
- const listener = () => {
324
- notificationCount++
325
- }
326
- const off = user.on('change', listener)
327
- user.name.set('Jane')
328
- expect(notificationCount).toBe(1)
329
-
330
- off()
331
- user.name.set('Bob')
332
- expect(notificationCount).toBe(1)
333
- })
334
- })
335
-
336
228
  describe('reactivity', () => {
337
229
  test('store-level get() is reactive', () => {
338
230
  const user = createStore({
@@ -660,4 +552,105 @@ describe('store', () => {
660
552
  expect(config.database.port.get()).toBe(5432)
661
553
  })
662
554
  })
555
+
556
+ describe('Watch Callbacks', () => {
557
+ test('Root store watched callback triggered only by direct store access', async () => {
558
+ let rootStoreCounter = 0
559
+ let intervalId: Timer | undefined
560
+ const store = createStore(
561
+ {
562
+ user: {
563
+ name: 'John',
564
+ profile: {
565
+ email: 'john@example.com',
566
+ },
567
+ },
568
+ },
569
+ {
570
+ watched: () => {
571
+ intervalId = setInterval(() => {
572
+ rootStoreCounter++
573
+ }, 10)
574
+ },
575
+ unwatched: () => {
576
+ if (intervalId) {
577
+ clearInterval(intervalId)
578
+ intervalId = undefined
579
+ }
580
+ },
581
+ },
582
+ )
583
+
584
+ expect(rootStoreCounter).toBe(0)
585
+ await new Promise(resolve => setTimeout(resolve, 50))
586
+ expect(rootStoreCounter).toBe(0)
587
+
588
+ // Access nested property directly - should NOT trigger root watched callback
589
+ const nestedEffectCleanup = createEffect(() => {
590
+ store.user.name.get()
591
+ })
592
+
593
+ await new Promise(resolve => setTimeout(resolve, 50))
594
+ expect(rootStoreCounter).toBe(0) // Still 0 - nested access doesn't trigger root
595
+ expect(intervalId).toBeUndefined()
596
+
597
+ // Access root store directly - should trigger watched callback
598
+ const rootEffectCleanup = createEffect(() => {
599
+ store.get()
600
+ })
601
+
602
+ await new Promise(resolve => setTimeout(resolve, 50))
603
+ expect(rootStoreCounter).toBeGreaterThan(0) // Now triggered
604
+ expect(intervalId).toBeDefined()
605
+
606
+ // Cleanup
607
+ rootEffectCleanup()
608
+ nestedEffectCleanup()
609
+ await new Promise(resolve => setTimeout(resolve, 50))
610
+ expect(intervalId).toBeUndefined()
611
+ })
612
+
613
+ test('Store property addition/removal affects store watched callback', async () => {
614
+ let usersStoreCounter = 0
615
+ const store = createStore(
616
+ {
617
+ users: {} as Record<string, { name: string }>,
618
+ },
619
+ {
620
+ watched: () => {
621
+ usersStoreCounter++
622
+ },
623
+ unwatched: () => {
624
+ usersStoreCounter--
625
+ },
626
+ },
627
+ )
628
+
629
+ expect(usersStoreCounter).toBe(0)
630
+
631
+ // Watch the entire store
632
+ const usersEffect = createEffect(() => {
633
+ store.get()
634
+ })
635
+ expect(usersStoreCounter).toBe(1)
636
+
637
+ // Add a user - this modifies the users store content but doesn't affect watched callback
638
+ store.users.add('user1', { name: 'Alice' })
639
+ expect(usersStoreCounter).toBe(1) // Still 1
640
+
641
+ // Watch a specific user property - this doesn't trigger users store watched callback
642
+ const userEffect = createEffect(() => {
643
+ store.users.user1?.name.get()
644
+ })
645
+ expect(usersStoreCounter).toBe(1) // Still 1
646
+
647
+ // Cleanup user effect
648
+ userEffect()
649
+ expect(usersStoreCounter).toBe(1) // Still active due to usersEffect
650
+
651
+ // Cleanup users effect
652
+ usersEffect()
653
+ expect(usersStoreCounter).toBe(0) // Now cleaned up
654
+ })
655
+ })
663
656
  })
@@ -124,7 +124,7 @@ function makeDependentRows(
124
124
  ): Computed<number>[][] {
125
125
  let prevRow = sources
126
126
  const rand = new Random('seed')
127
- const rows = []
127
+ const rows: Computed<number>[][] = []
128
128
  for (let l = 0; l < numRows; l++) {
129
129
  const row = makeRow(
130
130
  prevRow,
@@ -135,7 +135,7 @@ function makeDependentRows(
135
135
  l,
136
136
  rand,
137
137
  )
138
- rows.push(row as never)
138
+ rows.push(row)
139
139
  prevRow = row
140
140
  }
141
141
  return rows
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": false,
5
+ "declaration": true,
6
+ "declarationDir": "./types",
7
+ "emitDeclarationOnly": true,
8
+ },
9
+ "include": ["./index.ts", "./src/**/*.ts"],
10
+ "exclude": ["node_modules", "types", "test", "archive"],
11
+ }