@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/.ai-context.md CHANGED
@@ -279,6 +279,19 @@ const finalResults = processedItems.deriveCollection(item =>
279
279
  // Ref signal manual notifications
280
280
  elementRef.notify() // Notify when DOM element changes externally
281
281
  cacheRef.notify() // Notify when Map/Set changes externally
282
+
283
+ // Resource management with watch callbacks
284
+ const endpoint = new State('https://api.example.com', {
285
+ watched: () => {
286
+ console.log('Setting up API client...')
287
+ const resource = createResource(endpoint.get())
288
+ },
289
+ unwatched: () => {
290
+ console.log('Cleaning up API client...')
291
+ resource.cleanup()
292
+ }
293
+ })
294
+
282
295
  ```
283
296
 
284
297
  ## Build and Development
@@ -173,6 +173,10 @@ const activeUserSummaries = users
173
173
  .filter(Boolean)
174
174
  ```
175
175
 
176
+ ## Resource Management
177
+
178
+ All signals support `watched` and `unwatched` callbacks in signal configuration (optional second parameter) for lazy resource allocation. Resources are only created when signals are accessed by effects and automatically cleaned up when no longer watched.
179
+
176
180
  ## When suggesting code:
177
181
  1. Follow the established patterns for signal creation and usage
178
182
  2. Use proper TypeScript types and generics
@@ -0,0 +1,3 @@
1
+ {
2
+ "project_name": "Cause & Effect"
3
+ }
package/CLAUDE.md CHANGED
@@ -78,19 +78,14 @@ Key patterns:
78
78
 
79
79
  **Store signals** (`createStore`): Transform objects into reactive data structures
80
80
  - Each property becomes its own signal via Proxy
81
- - Built on `Composite` class for signal management
81
+ - Lazy signal creation and automatic cleanup
82
82
  - Dynamic property addition/removal with proper reactivity
83
83
 
84
84
  **List signals** (`new List`): Arrays with stable keys and reactive items
85
- - Maintains stable keys that survive sorting and reordering
85
+ - Maintains stable keys that survive sorting and splicing
86
86
  - Built on `Composite` class for consistent signal management
87
87
  - Provides `byKey()`, `keyAt()`, `indexOfKey()` for key-based access
88
88
 
89
- **Composite Architecture**: Shared foundation for Store and List
90
- - `Map<string, Signal>` for property/item signals
91
- - Event system for granular add/change/remove notifications
92
- - Lazy signal creation and automatic cleanup
93
-
94
89
  ### Computed Signal Memoization Strategy
95
90
 
96
91
  Computed signals implement smart memoization:
@@ -100,6 +95,45 @@ Computed signals implement smart memoization:
100
95
  - **Error Handling**: Preserves error states and prevents cascade failures
101
96
  - **Reducer Capabilities**: Access to previous value enables state accumulation and transitions
102
97
 
98
+ ## Resource Management with Watch Callbacks
99
+
100
+ All signals support the `watched` and `unwatched` callbacks for lazy resource management. Resources are allocated only when a signal is first accessed by an effect and automatically cleaned up when no effects are watching:
101
+
102
+ ```typescript
103
+ // Basic watch callbacks pattern
104
+ const config = new State({ apiUrl: 'https://api.example.com' }, {
105
+ watched: () => {
106
+ console.log('Setting up API client...')
107
+ const client = new ApiClient(config.get().apiUrl)
108
+ },
109
+ unwatched: () => {
110
+ console.log('Cleaning up API client...')
111
+ client.disconnect()
112
+ }
113
+ })
114
+
115
+ // Resource is only created when effect runs
116
+ const cleanup = createEffect(() => {
117
+ console.log('API URL:', config.get().apiUrl) // Triggers watched callback
118
+ })
119
+
120
+ cleanup() // Triggers unwatched callback
121
+ ```
122
+
123
+ **Practical Use Cases**:
124
+ - Event listeners that activate only when data is watched
125
+ - Network connections established on-demand
126
+ - Expensive computations that pause when not needed
127
+ - External subscriptions (WebSocket, Server-Sent Events)
128
+ - Database connections tied to data access patterns
129
+
130
+ **Watch Lifecycle**:
131
+ 1. First effect accesses signal → `watched` callback executed
132
+ 3. Last effect stops watching → `unwatched` callback executed
133
+ 4. New effect accesses signal → `watched` callback executed again
134
+
135
+ This pattern enables **lazy resource allocation** - resources are only consumed when actually needed and automatically freed when no longer used.
136
+
103
137
  ## Advanced Patterns and Best Practices
104
138
 
105
139
  ### When to Use Each Signal Type
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Cause & Effect
2
2
 
3
- Version 0.17.1
3
+ Version 0.17.3
4
4
 
5
- **Cause & Effect** is a tiny (~5kB gzipped), dependency-free reactive state library for JavaScript. It uses fine-grained signals so derived values and side effects update automatically when their dependencies change.
5
+ **Cause & Effect** is a tiny (~5kB gzipped), dependency-free reactive state management library for JavaScript. It uses fine-grained signals so derived values and side effects update automatically when their dependencies change.
6
6
 
7
7
  ## What is Cause & Effect?
8
8
 
@@ -159,35 +159,23 @@ user.preferences.theme.set('light')
159
159
  createEffect(() => console.log('User:', user.get()))
160
160
  ```
161
161
 
162
- Dynamic properties using the `add()` and `remove()` methods:
162
+ Iterator for keys using reactive `.keys()` method to observe structural changes:
163
163
 
164
164
  ```js
165
- const settings = createStore({ autoSave: true })
166
-
167
- settings.add('timeout', 5000)
168
- settings.remove('timeout')
165
+ for (const key of user.keys()) {
166
+ console.log(key)
167
+ }
169
168
  ```
170
169
 
171
- Change Notifications using the `.on()` method:
172
-
173
- ```js
174
- const user = createStore({ name: 'Alice', age: 30 })
175
-
176
- const offChange = user.on('change', changed => console.log(changed))
177
- const offAdd = user.on('add', added => console.log(added))
178
- const offRemove = user.on('remove', removed => console.log(removed))
170
+ Access items by key using `.byKey()` or via direct property access like `user.name` (enabled by the Proxy `createStore()` returns).
179
171
 
180
- // These will trigger the respective notifications:
181
- user.add('email', 'alice@example.com') // Logs: "Added properties: ['email']"
182
- user.age.set(31) // Logs: "Changed properties: ['age']"
183
- user.remove('email') // Logs: "Removed properties: ['email']"
184
-
185
- To stop listening to notifications, call the returned cleanup functions:
172
+ Dynamic properties using the `.add()` and `.remove()` methods:
186
173
 
187
174
  ```js
188
- offAdd() // Stop listening to add notifications
189
- offChange() // Stop listening to change notifications
190
- offRemove() // Stop listening to remove notifications
175
+ const settings = createStore({ autoSave: true })
176
+
177
+ settings.add('timeout', 5000)
178
+ settings.remove('timeout')
191
179
  ```
192
180
 
193
181
  ### List
@@ -206,6 +194,8 @@ items.splice(1, 1, 'orange')
206
194
  items.sort()
207
195
  ```
208
196
 
197
+ Access items by key using `.byKey()` or by index using `.at()`. `.indexOfKey()` returns the current index of an item in the list, while `.keyAt()` returns the key of an item at a given position.
198
+
209
199
  Keys are stable across reordering:
210
200
 
211
201
  ```js
@@ -217,7 +207,7 @@ console.log(items.byKey(key)) // 'orange'
217
207
  console.log(items.indexOfKey(key)) // current index
218
208
  ```
219
209
 
220
- Lists have `.add()`, `.remove()` and `.on()` methods like stores. In addition, they have `.sort()` and `.splice()` methods. But unlike stores, deeply nested properties in items are not converted to individual signals.
210
+ Lists have `.keys()`, `.add()`, and `.remove()` methods like stores. Additionally, they have `.sort()`, `.splice()`, and a reactive `.length` property. But unlike stores, deeply nested properties in items are not converted to individual signals. Lists have no Proxy layer and don't support direct property access like `items[0].name`.
221
211
 
222
212
  ### Collection
223
213
 
@@ -398,6 +388,39 @@ cleanup() // Logs: 'Cleanup' and unsubscribes from signal `user`
398
388
  user.set({ name: 'Bob', age: 28 }) // Won't trigger the effect anymore
399
389
  ```
400
390
 
391
+ ### Resource Management with Watch Callbacks
392
+
393
+ All signals support a options object with `watched` and `unwatched` callbacks for lazy resource management. Resources are only allocated when the signal is first accessed by an effect, and automatically cleaned up when no effects are watching:
394
+
395
+ ```js
396
+ import { State, createEffect } from '@zeix/cause-effect'
397
+
398
+ const config = new State({ apiUrl: 'https://api.example.com' }, {
399
+ watched: () => {
400
+ console.log('Setting up API client...')
401
+ const client = new ApiClient(config.get().apiUrl)
402
+ },
403
+ unwatched: () => {
404
+ console.log('Cleaning up API client...')
405
+ client.disconnect()
406
+ }
407
+ })
408
+
409
+ // Resource is created only when effect runs
410
+ const cleanup = createEffect(() => {
411
+ console.log('API URL:', config.get().apiUrl)
412
+ })
413
+
414
+ // Resource is cleaned up when effect stops
415
+ cleanup()
416
+ ```
417
+
418
+ This pattern is ideal for:
419
+ - Event listeners that should only be active when data is being watched
420
+ - Network connections that can be lazily established
421
+ - Expensive computations that should pause when not needed
422
+ - External subscriptions (WebSocket, Server-Sent Events, etc.)
423
+
401
424
  ### resolve()
402
425
 
403
426
  Extract signal values:
@@ -159,7 +159,6 @@ const benchmarkFactory = async () => {
159
159
  const memoryStores = await measureMemory('Factory Memory Usage', () => {
160
160
  const tempStores = []
161
161
  for (let i = 0; i < ITERATIONS; i++)
162
- // @ts-expect-error ignore
163
162
  tempStores.push(createFactoryStore({ ...testData, id: i }))
164
163
  return tempStores
165
164
  })
@@ -226,7 +225,6 @@ const benchmarkFactoryList = async () => {
226
225
  const tempLists = []
227
226
  for (let i = 0; i < ITERATIONS; i++) {
228
227
  tempLists.push(
229
- // @ts-expect-error ignore
230
228
  createFactoryList([
231
229
  ...testListData.map(item => ({
232
230
  ...item,
@@ -305,7 +303,6 @@ const benchmarkDirectClassList = async () => {
305
303
  const tempLists = []
306
304
  for (let i = 0; i < ITERATIONS; i++) {
307
305
  tempLists.push(
308
- // @ts-expect-error ignore
309
306
  new List([
310
307
  ...testListData.map(item => ({
311
308
  ...item,
@@ -375,7 +372,6 @@ const benchmarkClass = async () => {
375
372
  const memoryStores = await measureMemory('Class Memory Usage', () => {
376
373
  const tempStores = []
377
374
  for (let i = 0; i < ITERATIONS; i++)
378
- // @ts-expect-error ignore
379
375
  tempStores.push(createClassStore({ ...testData, id: i }))
380
376
  return tempStores
381
377
  })
@@ -436,7 +432,6 @@ const benchmarkDirectClass = async () => {
436
432
  () => {
437
433
  const tempStores = []
438
434
  for (let i = 0; i < ITERATIONS; i++)
439
- // @ts-expect-error ignore
440
435
  tempStores.push(new BaseStore({ ...testData, id: i }))
441
436
  return tempStores
442
437
  },
@@ -3,18 +3,13 @@ import { match } from '../src/match'
3
3
  import { resolve } from '../src/resolve'
4
4
  import type { Signal } from '../src/signal'
5
5
  import {
6
- type Cleanup,
7
6
  createWatcher,
8
- emitNotification,
9
- type Listener,
10
- type Listeners,
11
- type Notifications,
12
7
  notifyWatchers,
13
8
  subscribeActiveWatcher,
14
- trackSignalReads,
9
+ UNSET,
15
10
  type Watcher,
16
11
  } from '../src/system'
17
- import { isAsyncFunction, isObjectOfType, isSymbol, UNSET } from '../src/util'
12
+ import { isAsyncFunction, isObjectOfType, isSymbol } from '../src/util'
18
13
  import { type Computed, createComputed } from './computed'
19
14
  import type { List } from './list'
20
15
 
@@ -39,7 +34,6 @@ type Collection<T extends {}> = {
39
34
  get(): T[]
40
35
  keyAt(index: number): string | undefined
41
36
  indexOfKey(key: string): number
42
- on<K extends keyof Notifications>(type: K, listener: Listener<K>): Cleanup
43
37
  sort(compareFn?: (a: T, b: T) => number): void
44
38
  }
45
39
 
@@ -66,12 +60,6 @@ const createCollection = <T extends {}, O extends {}>(
66
60
  callback: CollectionCallback<T, O>,
67
61
  ): Collection<T> => {
68
62
  const watchers = new Set<Watcher>()
69
- const listeners: Listeners = {
70
- add: new Set<Listener<'add'>>(),
71
- change: new Set<Listener<'change'>>(),
72
- remove: new Set<Listener<'remove'>>(),
73
- sort: new Set<Listener<'sort'>>(),
74
- }
75
63
  const signals = new Map<string, Signal<T>>()
76
64
  const signalWatchers = new Map<string, Watcher>()
77
65
 
@@ -118,60 +106,23 @@ const createCollection = <T extends {}, O extends {}>(
118
106
  // Set internal states
119
107
  signals.set(key, signal)
120
108
  if (!order.includes(key)) order.push(key)
121
- const watcher = createWatcher(() =>
122
- trackSignalReads(watcher, () => {
109
+ const watcher = createWatcher(
110
+ () => {
123
111
  signal.get() // Subscribe to the signal
124
- emitNotification(listeners.change, [key])
125
- }),
112
+ },
113
+ () => {},
126
114
  )
127
115
  watcher()
128
116
  signalWatchers.set(key, watcher)
129
117
  return true
130
118
  }
131
119
 
132
- // Remove nested signal and effect
133
- const removeProperty = (key: string) => {
134
- // Remove signal for key
135
- const ok = signals.delete(key)
136
- if (!ok) return
137
-
138
- // Clean up internal states
139
- const index = order.indexOf(key)
140
- if (index >= 0) order.splice(index, 1)
141
- const watcher = signalWatchers.get(key)
142
- if (watcher) {
143
- watcher.stop()
144
- signalWatchers.delete(key)
145
- }
146
- }
147
-
148
120
  // Initialize properties
149
121
  for (let i = 0; i < origin.length; i++) {
150
122
  const key = origin.keyAt(i)
151
123
  if (!key) continue
152
124
  addProperty(key)
153
125
  }
154
- origin.on('add', additions => {
155
- for (const key of additions) {
156
- if (!signals.has(key)) addProperty(key)
157
- }
158
- notifyWatchers(watchers)
159
- emitNotification(listeners.add, additions)
160
- })
161
- origin.on('remove', removals => {
162
- for (const key of Object.keys(removals)) {
163
- if (!signals.has(key)) continue
164
- removeProperty(key)
165
- }
166
- order = order.filter(() => true) // Compact array
167
- notifyWatchers(watchers)
168
- emitNotification(listeners.remove, removals)
169
- })
170
- origin.on('sort', newOrder => {
171
- order = [...newOrder]
172
- notifyWatchers(watchers)
173
- emitNotification(listeners.sort, newOrder)
174
- })
175
126
 
176
127
  // Get signal by key or index
177
128
  const getSignal = (prop: string): Signal<T> | undefined => {
@@ -247,16 +198,6 @@ const createCollection = <T extends {}, O extends {}>(
247
198
  order = entries.map(([_, key]) => key)
248
199
 
249
200
  notifyWatchers(watchers)
250
- emitNotification(listeners.sort, order)
251
- },
252
- },
253
- on: {
254
- value: <K extends keyof Listeners>(
255
- type: K,
256
- listener: Listener<K>,
257
- ): Cleanup => {
258
- listeners[type].add(listener)
259
- return () => listeners[type].delete(listener)
260
201
  },
261
202
  },
262
203
  length: {
@@ -0,0 +1,85 @@
1
+ import type { DiffResult, UnknownRecord } from '../src/diff'
2
+ import { guardMutableSignal } from '../src/errors'
3
+ import type { Signal } from '../src/signal'
4
+ import { batch } from '../src/system'
5
+
6
+ /* === Class Definitions === */
7
+
8
+ class Composite<T extends UnknownRecord, S extends Signal<T[keyof T] & {}>> {
9
+ signals = new Map<string, S>()
10
+ #validate: <K extends keyof T & string>(
11
+ key: K,
12
+ value: unknown,
13
+ ) => value is T[K] & {}
14
+ #create: <V extends T[keyof T] & {}>(value: V) => S
15
+
16
+ constructor(
17
+ values: T,
18
+ validate: <K extends keyof T & string>(
19
+ key: K,
20
+ value: unknown,
21
+ ) => value is T[K] & {},
22
+ create: <V extends T[keyof T] & {}>(value: V) => S,
23
+ ) {
24
+ this.#validate = validate
25
+ this.#create = create
26
+ this.change({
27
+ add: values,
28
+ change: {},
29
+ remove: {},
30
+ changed: true,
31
+ })
32
+ }
33
+
34
+ add<K extends keyof T & string>(key: K, value: T[K]): boolean {
35
+ if (!this.#validate(key, value)) return false
36
+
37
+ this.signals.set(key, this.#create(value))
38
+ return true
39
+ }
40
+
41
+ remove<K extends keyof T & string>(key: K): boolean {
42
+ return this.signals.delete(key)
43
+ }
44
+
45
+ change(changes: DiffResult): boolean {
46
+ // Additions
47
+ if (Object.keys(changes.add).length) {
48
+ for (const key in changes.add)
49
+ this.add(
50
+ key as Extract<keyof T, string>,
51
+ changes.add[key] as T[Extract<keyof T, string>] & {},
52
+ )
53
+ }
54
+
55
+ // Changes
56
+ if (Object.keys(changes.change).length) {
57
+ batch(() => {
58
+ for (const key in changes.change) {
59
+ const value = changes.change[key]
60
+ if (!this.#validate(key as keyof T & string, value))
61
+ continue
62
+
63
+ const signal = this.signals.get(key)
64
+ if (guardMutableSignal(`list item "${key}"`, value, signal))
65
+ signal.set(value)
66
+ }
67
+ })
68
+ }
69
+
70
+ // Removals
71
+ if (Object.keys(changes.remove).length) {
72
+ for (const key in changes.remove)
73
+ this.remove(key as keyof T & string)
74
+ }
75
+
76
+ return changes.changed
77
+ }
78
+
79
+ clear(): boolean {
80
+ this.signals.clear()
81
+ return true
82
+ }
83
+ }
84
+
85
+ export { Composite }
@@ -7,10 +7,10 @@ import {
7
7
  } from '../src/errors'
8
8
  import {
9
9
  createWatcher,
10
- flushPendingReactions,
10
+ flush,
11
11
  notifyWatchers,
12
12
  subscribeActiveWatcher,
13
- trackSignalReads,
13
+ UNSET,
14
14
  type Watcher,
15
15
  } from '../src/system'
16
16
  import {
@@ -18,7 +18,6 @@ import {
18
18
  isAsyncFunction,
19
19
  isFunction,
20
20
  isObjectOfType,
21
- UNSET,
22
21
  } from '../src/util'
23
22
 
24
23
  /* === Types === */
@@ -96,19 +95,14 @@ const createComputed = <T extends {}>(
96
95
  }
97
96
 
98
97
  // Own watcher: called when notified from sources (push)
99
- const watcher = createWatcher(() => {
100
- dirty = true
101
- controller?.abort()
102
- if (watchers.size) notifyWatchers(watchers)
103
- else watcher.stop()
104
- })
105
- watcher.onCleanup(() => {
106
- controller?.abort()
107
- })
108
-
109
- // Called when requested by dependencies (pull)
110
- const compute = () =>
111
- trackSignalReads(watcher, () => {
98
+ const watcher = createWatcher(
99
+ () => {
100
+ dirty = true
101
+ controller?.abort()
102
+ if (watchers.size) notifyWatchers(watchers)
103
+ else watcher.stop()
104
+ },
105
+ () => {
112
106
  if (computing) throw new CircularDependencyError('computed')
113
107
  changed = false
114
108
  if (isAsyncFunction(callback)) {
@@ -120,7 +114,7 @@ const createComputed = <T extends {}>(
120
114
  () => {
121
115
  computing = false
122
116
  controller = undefined
123
- compute() // Retry computation with updated state
117
+ watcher.run() // Retry computation with updated state
124
118
  },
125
119
  {
126
120
  once: true,
@@ -143,7 +137,11 @@ const createComputed = <T extends {}>(
143
137
  else if (null == result || UNSET === result) nil()
144
138
  else ok(result)
145
139
  computing = false
146
- })
140
+ },
141
+ )
142
+ watcher.onCleanup(() => {
143
+ controller?.abort()
144
+ })
147
145
 
148
146
  const computed: Record<PropertyKey, unknown> = {}
149
147
  Object.defineProperties(computed, {
@@ -153,8 +151,8 @@ const createComputed = <T extends {}>(
153
151
  get: {
154
152
  value: (): T => {
155
153
  subscribeActiveWatcher(watchers)
156
- flushPendingReactions()
157
- if (dirty) compute()
154
+ flush()
155
+ if (dirty) watcher.run()
158
156
  if (error) throw error
159
157
  return value
160
158
  },