@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/src/system.ts CHANGED
@@ -1,26 +1,24 @@
1
+ import { assert, type Guard } from './errors'
2
+ import type { UnknownSignal } from './signal'
3
+
1
4
  /* === Types === */
2
5
 
3
6
  type Cleanup = () => void
4
7
 
8
+ // biome-ignore lint/suspicious/noConfusingVoidType: optional Cleanup return type
9
+ type MaybeCleanup = Cleanup | undefined | void
10
+
5
11
  type Watcher = {
6
12
  (): void
13
+ run(): void
7
14
  onCleanup(cleanup: Cleanup): void
8
15
  stop(): void
9
16
  }
10
17
 
11
- type Notifications = {
12
- add: readonly string[]
13
- change: readonly string[]
14
- remove: readonly string[]
15
- sort: readonly string[]
16
- }
17
-
18
- type Listener<K extends keyof Notifications> = (
19
- payload: Notifications[K],
20
- ) => void
21
-
22
- type Listeners = {
23
- [K in keyof Notifications]: Set<Listener<K>>
18
+ type SignalOptions<T extends unknown & {}> = {
19
+ guard?: Guard<T>
20
+ watched?: () => void
21
+ unwatched?: () => void
24
22
  }
25
23
 
26
24
  /* === Internal === */
@@ -28,94 +26,206 @@ type Listeners = {
28
26
  // Currently active watcher
29
27
  let activeWatcher: Watcher | undefined
30
28
 
29
+ const watchersMap = new WeakMap<UnknownSignal, Set<Watcher>>()
30
+ const watchedCallbackMap = new WeakMap<object, () => void>()
31
+ const unwatchedCallbackMap = new WeakMap<object, () => void>()
32
+
31
33
  // Queue of pending watcher reactions for batched change notifications
32
34
  const pendingReactions = new Set<() => void>()
33
35
  let batchDepth = 0
34
36
 
37
+ /* === Constants === */
38
+
39
+ // biome-ignore lint/suspicious/noExplicitAny: Deliberately using any to be used as a placeholder value in any signal
40
+ const UNSET: any = Symbol()
41
+
35
42
  /* === Functions === */
36
43
 
37
44
  /**
38
- * Create a watcher that can be used to observe changes to a signal
45
+ * Create a watcher to observe changes in signals.
39
46
  *
40
- * A watcher is a reaction function with onCleanup and stop methods
47
+ * A watcher combines push and pull reaction functions with onCleanup and stop methods
41
48
  *
42
- * @since 0.14.1
43
- * @param {() => void} react - Function to be called when the state changes
49
+ * @since 0.17.3
50
+ * @param {() => void} push - Function to be called when the state changes (push)
51
+ * @param {() => void} pull - Function to be called on demand from consumers (pull)
44
52
  * @returns {Watcher} - Watcher object with off and cleanup methods
45
53
  */
46
- const createWatcher = (react: () => void): Watcher => {
54
+ const createWatcher = (push: () => void, pull: () => void): Watcher => {
47
55
  const cleanups = new Set<Cleanup>()
48
- const watcher = react as Partial<Watcher>
56
+ const watcher = push as Partial<Watcher>
57
+ watcher.run = () => {
58
+ const prev = activeWatcher
59
+ activeWatcher = watcher as Watcher
60
+ try {
61
+ pull()
62
+ } finally {
63
+ activeWatcher = prev
64
+ }
65
+ }
49
66
  watcher.onCleanup = (cleanup: Cleanup) => {
50
67
  cleanups.add(cleanup)
51
68
  }
52
69
  watcher.stop = () => {
53
- for (const cleanup of cleanups) cleanup()
54
- cleanups.clear()
70
+ try {
71
+ for (const cleanup of cleanups) cleanup()
72
+ } finally {
73
+ cleanups.clear()
74
+ }
55
75
  }
56
76
  return watcher as Watcher
57
77
  }
58
78
 
59
79
  /**
60
- * Subscribe by adding active watcher to the Set of watchers of a signal
80
+ * Run a function with signal reads in a non-tracking context.
61
81
  *
62
- * @param {Set<Watcher>} watchers - Watchers of the signal
82
+ * @param {() => void} callback - Callback
63
83
  */
84
+ const untrack = (callback: () => void): void => {
85
+ const prev = activeWatcher
86
+ activeWatcher = undefined
87
+ try {
88
+ callback()
89
+ } finally {
90
+ activeWatcher = prev
91
+ }
92
+ }
93
+
94
+ const registerWatchCallbacks = (
95
+ signal: UnknownSignal,
96
+ watched: () => void,
97
+ unwatched?: () => void,
98
+ ) => {
99
+ watchedCallbackMap.set(signal, watched)
100
+ if (unwatched) unwatchedCallbackMap.set(signal, unwatched)
101
+ }
102
+
103
+ /**
104
+ * Subscribe active watcher to a signal.
105
+ *
106
+ * @param {UnknownSignal} signal - Signal to subscribe to
107
+ * @returns {boolean} - true if the active watcher was subscribed,
108
+ * false if the watcher was already subscribed or there was no active watcher
109
+ */
110
+ const subscribeTo = (signal: UnknownSignal): boolean => {
111
+ if (!activeWatcher || watchersMap.get(signal)?.has(activeWatcher))
112
+ return false
113
+
114
+ const watcher = activeWatcher
115
+ if (!watchersMap.has(signal)) watchersMap.set(signal, new Set<Watcher>())
116
+
117
+ const watchers = watchersMap.get(signal)
118
+ assert(watchers)
119
+ if (!watchers.size) {
120
+ const watchedCallback = watchedCallbackMap.get(signal)
121
+ if (watchedCallback) untrack(watchedCallback)
122
+ }
123
+ watchers.add(watcher)
124
+ watcher.onCleanup(() => {
125
+ watchers.delete(watcher)
126
+ if (!watchers.size) {
127
+ const unwatchedCallback = unwatchedCallbackMap.get(signal)
128
+ if (unwatchedCallback) untrack(unwatchedCallback)
129
+ }
130
+ })
131
+ return true
132
+ }
133
+
64
134
  const subscribeActiveWatcher = (watchers: Set<Watcher>) => {
65
- if (activeWatcher && !watchers.has(activeWatcher)) {
66
- const watcher = activeWatcher
67
- watcher.onCleanup(() => watchers.delete(watcher))
68
- watchers.add(watcher)
135
+ if (!activeWatcher || watchers.has(activeWatcher)) return false
136
+
137
+ const watcher = activeWatcher
138
+ watchers.add(watcher)
139
+ if (!watchers.size) {
140
+ const watchedCallback = watchedCallbackMap.get(watchers)
141
+ if (watchedCallback) untrack(watchedCallback)
69
142
  }
143
+ watcher.onCleanup(() => {
144
+ watchers.delete(watcher)
145
+ if (!watchers.size) {
146
+ const unwatchedCallback = unwatchedCallbackMap.get(watchers)
147
+ if (unwatchedCallback) untrack(unwatchedCallback)
148
+ }
149
+ })
150
+ return true
70
151
  }
71
152
 
72
153
  /**
73
- * Notify watchers of a signal change
154
+ * Unsubscribe all watchers from a signal so it can be garbage collected.
74
155
  *
75
- * @param {Set<Watcher>} watchers - Watchers of the signal
156
+ * @param {UnknownSignal} signal - Signal to unsubscribe from
157
+ * @returns {void}
76
158
  */
77
- const notifyWatchers = (watchers: Set<Watcher>) => {
159
+ const unsubscribeAllFrom = (signal: UnknownSignal): void => {
160
+ const watchers = watchersMap.get(signal)
161
+ if (!watchers) return
162
+
163
+ for (const watcher of watchers) watcher.stop()
164
+ watchers.clear()
165
+ }
166
+
167
+ /**
168
+ * Notify watchers of a signal change.
169
+ *
170
+ * @param {UnknownSignal} signal - Signal to notify watchers of
171
+ * @returns {boolean} - Whether any watchers were notified
172
+ */
173
+ const notifyOf = (signal: UnknownSignal): boolean => {
174
+ const watchers = watchersMap.get(signal)
175
+ if (!watchers?.size) return false
176
+
78
177
  for (const react of watchers) {
79
178
  if (batchDepth) pendingReactions.add(react)
80
179
  else react()
81
180
  }
181
+ return true
182
+ }
183
+
184
+ const notifyWatchers = (watchers: Set<Watcher>): boolean => {
185
+ if (!watchers.size) return false
186
+
187
+ for (const react of watchers) {
188
+ if (batchDepth) pendingReactions.add(react)
189
+ else react()
190
+ }
191
+ return true
82
192
  }
83
193
 
84
194
  /**
85
- * Flush all pending reactions of enqueued watchers
195
+ * Flush all pending reactions of enqueued watchers.
86
196
  */
87
- const flushPendingReactions = () => {
197
+ const flush = () => {
88
198
  while (pendingReactions.size) {
89
199
  const watchers = Array.from(pendingReactions)
90
200
  pendingReactions.clear()
91
- for (const watcher of watchers) watcher()
201
+ for (const react of watchers) react()
92
202
  }
93
203
  }
94
204
 
95
205
  /**
96
- * Batch multiple signal writes
206
+ * Batch multiple signal writes.
97
207
  *
98
208
  * @param {() => void} callback - Function with multiple signal writes to be batched
99
209
  */
100
- const batchSignalWrites = (callback: () => void) => {
210
+ const batch = (callback: () => void) => {
101
211
  batchDepth++
102
212
  try {
103
213
  callback()
104
214
  } finally {
105
- flushPendingReactions()
215
+ flush()
106
216
  batchDepth--
107
217
  }
108
218
  }
109
219
 
110
220
  /**
111
- * Run a function with signal reads in a tracking context (or temporarily untrack)
221
+ * Run a function with signal reads in a tracking context (or temporarily untrack).
112
222
  *
113
223
  * @param {Watcher | false} watcher - Watcher to be called when the signal changes
114
224
  * or false for temporary untracking while inserting auto-hydrating DOM nodes
115
225
  * that might read signals (e.g., Web Components)
116
226
  * @param {() => void} run - Function to run the computation or effect
117
227
  */
118
- const trackSignalReads = (watcher: Watcher | false, run: () => void): void => {
228
+ const track = (watcher: Watcher | false, run: () => void): void => {
119
229
  const prev = activeWatcher
120
230
  activeWatcher = watcher || undefined
121
231
  try {
@@ -125,35 +235,23 @@ const trackSignalReads = (watcher: Watcher | false, run: () => void): void => {
125
235
  }
126
236
  }
127
237
 
128
- /**
129
- * Emit a notification to listeners
130
- *
131
- * @param {Set<Listener>} listeners - Listeners to be notified
132
- * @param {Notifications[K]} payload - Payload to be sent to listeners
133
- */
134
- const emitNotification = <T extends keyof Notifications>(
135
- listeners: Set<Listener<T>>,
136
- payload: Notifications[T],
137
- ) => {
138
- for (const listener of listeners) {
139
- if (batchDepth) pendingReactions.add(() => listener(payload))
140
- else listener(payload)
141
- }
142
- }
143
-
144
238
  /* === Exports === */
145
239
 
146
240
  export {
147
241
  type Cleanup,
242
+ type MaybeCleanup,
148
243
  type Watcher,
149
- type Notifications,
150
- type Listener,
151
- type Listeners,
244
+ type SignalOptions,
245
+ UNSET,
152
246
  createWatcher,
247
+ registerWatchCallbacks,
248
+ subscribeTo,
153
249
  subscribeActiveWatcher,
250
+ unsubscribeAllFrom,
251
+ notifyOf,
154
252
  notifyWatchers,
155
- flushPendingReactions,
156
- batchSignalWrites,
157
- trackSignalReads,
158
- emitNotification,
253
+ flush,
254
+ batch,
255
+ track,
256
+ untrack,
159
257
  }
package/src/util.ts CHANGED
@@ -1,8 +1,3 @@
1
- /* === Constants === */
2
-
3
- // biome-ignore lint/suspicious/noExplicitAny: Deliberately using any to be used as a placeholder value in any signal
4
- const UNSET: any = Symbol()
5
-
6
1
  /* === Utility Functions === */
7
2
 
8
3
  const isString = /*#__PURE__*/ (value: unknown): value is string =>
@@ -73,7 +68,6 @@ const valueString = /*#__PURE__*/ (value: unknown): string =>
73
68
  /* === Exports === */
74
69
 
75
70
  export {
76
- UNSET,
77
71
  isString,
78
72
  isNumber,
79
73
  isSymbol,
@@ -1,12 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import {
3
- batchSignalWrites,
4
- createEffect,
5
- Memo,
6
- match,
7
- resolve,
8
- State,
9
- } from '../index.ts'
2
+ import { batch, createEffect, Memo, match, resolve, State } from '../index.ts'
10
3
 
11
4
  /* === Tests === */
12
5
 
@@ -19,7 +12,7 @@ describe('Batch', () => {
19
12
  result = cause.get()
20
13
  count++
21
14
  })
22
- batchSignalWrites(() => {
15
+ batch(() => {
23
16
  for (let i = 1; i <= 10; i++) cause.set(i)
24
17
  })
25
18
  expect(result).toBe(10)
@@ -43,7 +36,7 @@ describe('Batch', () => {
43
36
  err: () => {},
44
37
  })
45
38
  })
46
- batchSignalWrites(() => {
39
+ batch(() => {
47
40
  a.set(6)
48
41
  b.set(8)
49
42
  c.set(10)
@@ -87,7 +80,7 @@ describe('Batch', () => {
87
80
  expect(result).toBe(10)
88
81
 
89
82
  // Batch: apply changes to all signals in a single transaction
90
- batchSignalWrites(() => {
83
+ batch(() => {
91
84
  signals.forEach(signal => signal.update(v => v * 2))
92
85
  })
93
86
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, mock, test } from 'bun:test'
2
- import { batchSignalWrites, createEffect, Memo, State } from '../index.ts'
2
+ import { batch, createEffect, Memo, State } from '../index.ts'
3
3
  import { Counter, makeGraph, runGraph } from './util/dependency-graph'
4
4
  import type { Computed, ReactiveFramework } from './util/reactive-framework'
5
5
 
@@ -28,7 +28,7 @@ const framework = {
28
28
  }
29
29
  },
30
30
  effect: (fn: () => undefined) => createEffect(fn),
31
- withBatch: (fn: () => undefined) => batchSignalWrites(fn),
31
+ withBatch: (fn: () => undefined) => batch(fn),
32
32
  withBuild: <T>(fn: () => T) => fn(),
33
33
  }
34
34
  const testPullCounts = true
@@ -449,6 +449,7 @@ describe('$mol_wire tests', () => {
449
449
  const name = framework.name
450
450
 
451
451
  test(`${name} | $mol_wire benchmark`, () => {
452
+ // @ts-expect-error test
452
453
  const fib = (n: number) => {
453
454
  if (n < 2) return 1
454
455
  return fib(n - 1) + fib(n - 2)
@@ -618,6 +619,7 @@ describe('CellX tests', () => {
618
619
  for (const layers in expected) {
619
620
  // @ts-expect-error - Framework object has incompatible type constraints with ReactiveFramework
620
621
  const [before, after] = cellx(framework, layers)
622
+ // @ts-expect-error - Framework object has incompatible type constraints with ReactiveFramework
621
623
  const [expectedBefore, expectedAfter] = expected[layers]
622
624
  expect(before.toString()).toBe(expectedBefore.toString())
623
625
  expect(after.toString()).toBe(expectedAfter.toString())
@@ -104,7 +104,7 @@ describe('collection', () => {
104
104
  test('returns undefined for non-existent properties', () => {
105
105
  const items = new List([1, 2])
106
106
  const collection = new DerivedCollection(items, (x: number) => x)
107
- expect(collection[5]).toBeUndefined()
107
+ expect(collection.at(5)).toBeUndefined()
108
108
  })
109
109
 
110
110
  test('supports numeric key access', () => {
@@ -230,50 +230,6 @@ describe('collection', () => {
230
230
  })
231
231
  })
232
232
 
233
- describe('change notifications', () => {
234
- test('emits add notifications', () => {
235
- const numbers = new List([1, 2])
236
- const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
237
-
238
- let arrayAddNotification: readonly string[] = []
239
- doubled.on('add', keys => {
240
- arrayAddNotification = keys
241
- })
242
-
243
- numbers.add(3)
244
- expect(arrayAddNotification).toHaveLength(1)
245
- // biome-ignore lint/style/noNonNullAssertion: test
246
- expect(doubled.byKey(arrayAddNotification[0]!)?.get()).toBe(6)
247
- })
248
-
249
- test('emits remove notifications when items are removed', () => {
250
- const items = new List([1, 2, 3])
251
- const doubled = new DerivedCollection(items, (x: number) => x * 2)
252
-
253
- let arrayRemoveNotification: readonly string[] = []
254
- doubled.on('remove', keys => {
255
- arrayRemoveNotification = keys
256
- })
257
-
258
- items.remove(1)
259
- expect(arrayRemoveNotification).toHaveLength(1)
260
- })
261
-
262
- test('emits sort notifications when source is sorted', () => {
263
- const numbers = new List([3, 1, 2])
264
- const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
265
-
266
- let sortNotification: readonly string[] = []
267
- doubled.on('sort', newOrder => {
268
- sortNotification = newOrder
269
- })
270
-
271
- numbers.sort((a, b) => a - b)
272
- expect(sortNotification).toHaveLength(3)
273
- expect(doubled.get()).toEqual([2, 4, 6])
274
- })
275
- })
276
-
277
233
  describe('edge cases', () => {
278
234
  test('handles empty collections correctly', () => {
279
235
  const empty = new List<number>([])
@@ -721,68 +677,6 @@ describe('collection', () => {
721
677
  })
722
678
  })
723
679
 
724
- describe('derived collection event handling', () => {
725
- test('emits add events when source adds items', () => {
726
- const numbers = new List([1, 2])
727
- const doubled = new DerivedCollection(
728
- numbers,
729
- (x: number) => x * 2,
730
- )
731
- const quadrupled = doubled.deriveCollection(
732
- (x: number) => x * 2,
733
- )
734
-
735
- let addedKeys: readonly string[] = []
736
- quadrupled.on('add', keys => {
737
- addedKeys = keys
738
- })
739
-
740
- numbers.add(3)
741
- expect(addedKeys).toHaveLength(1)
742
- // biome-ignore lint/style/noNonNullAssertion: test
743
- expect(quadrupled.byKey(addedKeys[0]!)?.get()).toBe(12)
744
- })
745
-
746
- test('emits remove events when source removes items', () => {
747
- const numbers = new List([1, 2, 3])
748
- const doubled = new DerivedCollection(
749
- numbers,
750
- (x: number) => x * 2,
751
- )
752
- const quadrupled = doubled.deriveCollection(
753
- (x: number) => x * 2,
754
- )
755
-
756
- let removedKeys: readonly string[] = []
757
- quadrupled.on('remove', keys => {
758
- removedKeys = keys
759
- })
760
-
761
- numbers.remove(1)
762
- expect(removedKeys).toHaveLength(1)
763
- })
764
-
765
- test('emits sort events when source is sorted', () => {
766
- const numbers = new List([3, 1, 2])
767
- const doubled = new DerivedCollection(
768
- numbers,
769
- (x: number) => x * 2,
770
- )
771
- const quadrupled = doubled.deriveCollection(
772
- (x: number) => x * 2,
773
- )
774
-
775
- let sortedKeys: readonly string[] = []
776
- quadrupled.on('sort', newOrder => {
777
- sortedKeys = newOrder
778
- })
779
-
780
- numbers.sort((a, b) => a - b)
781
- expect(sortedKeys).toHaveLength(3)
782
- expect(quadrupled.get()).toEqual([4, 8, 12])
783
- })
784
- })
785
-
786
680
  describe('edge cases', () => {
787
681
  test('handles empty collection derivation', () => {
788
682
  const empty = new List<number>([])
@@ -850,4 +744,109 @@ describe('collection', () => {
850
744
  })
851
745
  })
852
746
  })
747
+
748
+ describe('Watch Callbacks', () => {
749
+ test('Collection watched callback is called when effect accesses collection.get()', () => {
750
+ const numbers = new List([10, 20, 30])
751
+
752
+ let collectionWatchedCalled = false
753
+ let collectionUnwatchCalled = false
754
+ const doubled = numbers.deriveCollection(x => x * 2, {
755
+ watched: () => {
756
+ collectionWatchedCalled = true
757
+ },
758
+ unwatched: () => {
759
+ collectionUnwatchCalled = true
760
+ },
761
+ })
762
+
763
+ expect(collectionWatchedCalled).toBe(false)
764
+
765
+ // Access collection via collection.get() - this should trigger collection's watched callback
766
+ let effectValue: number[] = []
767
+ const cleanup = createEffect(() => {
768
+ effectValue = doubled.get()
769
+ })
770
+
771
+ expect(collectionWatchedCalled).toBe(true)
772
+ expect(effectValue).toEqual([20, 40, 60])
773
+ expect(collectionUnwatchCalled).toBe(false)
774
+
775
+ // Cleanup effect - should trigger unwatch
776
+ cleanup()
777
+ expect(collectionUnwatchCalled).toBe(true)
778
+ })
779
+
780
+ test('Collection and List watched callbacks work independently', () => {
781
+ let sourceWatchedCalled = false
782
+ const items = new List(['hello', 'world'], {
783
+ watched: () => {
784
+ sourceWatchedCalled = true
785
+ },
786
+ })
787
+
788
+ let collectionWatchedCalled = false
789
+ let collectionUnwatchedCalled = false
790
+ const uppercased = items.deriveCollection(x => x.toUpperCase(), {
791
+ watched: () => {
792
+ collectionWatchedCalled = true
793
+ },
794
+ unwatched: () => {
795
+ collectionUnwatchedCalled = true
796
+ },
797
+ })
798
+
799
+ // Effect 1: Access collection-level data - triggers both watched callbacks
800
+ let collectionValue: string[] = []
801
+ const collectionCleanup = createEffect(() => {
802
+ collectionValue = uppercased.get()
803
+ })
804
+
805
+ expect(collectionWatchedCalled).toBe(true)
806
+ expect(sourceWatchedCalled).toBe(true) // Source items accessed by collection.get()
807
+ expect(collectionValue).toEqual(['HELLO', 'WORLD'])
808
+
809
+ // Effect 2: Access individual collection item independently
810
+ let itemValue: string | undefined
811
+ const itemCleanup = createEffect(() => {
812
+ itemValue = uppercased.at(0)?.get()
813
+ })
814
+
815
+ expect(itemValue).toBe('HELLO')
816
+
817
+ // Clean up effects
818
+ collectionCleanup()
819
+ expect(collectionUnwatchedCalled).toBe(true)
820
+
821
+ itemCleanup()
822
+ })
823
+
824
+ test('Collection length access triggers Collection watched callback', () => {
825
+ const numbers = new List([1, 2, 3])
826
+
827
+ let collectionWatchedCalled = false
828
+ let collectionUnwatchedCalled = false
829
+ const doubled = numbers.deriveCollection(x => x * 2, {
830
+ watched: () => {
831
+ collectionWatchedCalled = true
832
+ },
833
+ unwatched: () => {
834
+ collectionUnwatchedCalled = true
835
+ },
836
+ })
837
+
838
+ // Access via collection.length - this should trigger collection's watched callback
839
+ let effectValue: number = 0
840
+ const cleanup = createEffect(() => {
841
+ effectValue = doubled.length
842
+ })
843
+
844
+ expect(collectionWatchedCalled).toBe(true)
845
+ expect(effectValue).toBe(3)
846
+ expect(collectionUnwatchedCalled).toBe(false)
847
+
848
+ cleanup()
849
+ expect(collectionUnwatchedCalled).toBe(true)
850
+ })
851
+ })
853
852
  })