@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
@@ -7,10 +7,12 @@ import {
7
7
  } from '../errors'
8
8
  import {
9
9
  createWatcher,
10
- flushPendingReactions,
11
- notifyWatchers,
12
- subscribeActiveWatcher,
13
- trackSignalReads,
10
+ flush,
11
+ notifyOf,
12
+ registerWatchCallbacks,
13
+ type SignalOptions,
14
+ subscribeTo,
15
+ UNSET,
14
16
  type Watcher,
15
17
  } from '../system'
16
18
  import {
@@ -18,7 +20,6 @@ import {
18
20
  isAsyncFunction,
19
21
  isObjectOfType,
20
22
  isSyncFunction,
21
- UNSET,
22
23
  } from '../util'
23
24
 
24
25
  /* === Types === */
@@ -28,6 +29,10 @@ type Computed<T extends {}> = {
28
29
  get(): T
29
30
  }
30
31
 
32
+ type ComputedOptions<T extends {}> = SignalOptions<T> & {
33
+ initialValue?: T
34
+ }
35
+
31
36
  type MemoCallback<T extends {} & { then?: undefined }> = (oldValue: T) => T
32
37
 
33
38
  type TaskCallback<T extends {} & { then?: undefined }> = (
@@ -45,56 +50,38 @@ const TYPE_COMPUTED = 'Computed' as const
45
50
  * Create a new memoized signal for a synchronous function.
46
51
  *
47
52
  * @since 0.17.0
53
+ * @param {MemoCallback<T>} callback - Callback function to compute the memoized value
54
+ * @param {T} [initialValue = UNSET] - Initial value of the signal
55
+ * @throws {InvalidCallbackError} If the callback is not an sync function
56
+ * @throws {InvalidSignalValueError} If the initial value is not valid
48
57
  */
49
58
  class Memo<T extends {}> {
50
- #watchers: Set<Watcher> = new Set()
51
59
  #callback: MemoCallback<T>
52
60
  #value: T
53
61
  #error: Error | undefined
54
62
  #dirty = true
55
63
  #computing = false
56
- #watcher: Watcher
64
+ #watcher: Watcher | undefined
57
65
 
58
- /**
59
- * Create a new memoized signal.
60
- *
61
- * @param {MemoCallback<T>} callback - Callback function to compute the memoized value
62
- * @param {T} [initialValue = UNSET] - Initial value of the signal
63
- * @throws {InvalidCallbackError} If the callback is not an sync function
64
- * @throws {InvalidSignalValueError} If the initial value is not valid
65
- */
66
- constructor(callback: MemoCallback<T>, initialValue: T = UNSET) {
67
- validateCallback('memo', callback, isMemoCallback)
68
- validateSignalValue('memo', initialValue)
66
+ constructor(callback: MemoCallback<T>, options?: ComputedOptions<T>) {
67
+ validateCallback(this.constructor.name, callback, isMemoCallback)
68
+ const initialValue = options?.initialValue ?? UNSET
69
+ validateSignalValue(this.constructor.name, initialValue, options?.guard)
69
70
 
70
71
  this.#callback = callback
71
72
  this.#value = initialValue
72
-
73
- // Own watcher: called by notifyWatchers() in upstream signals (push)
74
- this.#watcher = createWatcher(() => {
75
- this.#dirty = true
76
- if (this.#watchers.size) notifyWatchers(this.#watchers)
77
- else this.#watcher.stop()
78
- })
79
- }
80
-
81
- get [Symbol.toStringTag](): 'Computed' {
82
- return TYPE_COMPUTED
73
+ if (options?.watched)
74
+ registerWatchCallbacks(this, options.watched, options.unwatched)
83
75
  }
84
76
 
85
- /**
86
- * Return the memoized value after computing it if necessary.
87
- *
88
- * @returns {T}
89
- * @throws {CircularDependencyError} If a circular dependency is detected
90
- * @throws {Error} If an error occurs during computation
91
- */
92
- get(): T {
93
- subscribeActiveWatcher(this.#watchers)
94
- flushPendingReactions()
95
-
96
- if (this.#dirty) {
97
- trackSignalReads(this.#watcher, () => {
77
+ #getWatcher(): Watcher {
78
+ // Own watcher: called by notifyWatchers() in upstream signals (push)
79
+ this.#watcher ||= createWatcher(
80
+ () => {
81
+ this.#dirty = true
82
+ if (!notifyOf(this)) this.#watcher?.stop()
83
+ },
84
+ () => {
98
85
  if (this.#computing) throw new CircularDependencyError('memo')
99
86
 
100
87
  let result: T
@@ -120,9 +107,31 @@ class Memo<T extends {}> {
120
107
  this.#dirty = false
121
108
  }
122
109
  this.#computing = false
123
- })
124
- }
110
+ },
111
+ )
112
+ this.#watcher.onCleanup(() => {
113
+ this.#watcher = undefined
114
+ })
115
+
116
+ return this.#watcher
117
+ }
118
+
119
+ get [Symbol.toStringTag](): 'Computed' {
120
+ return TYPE_COMPUTED
121
+ }
125
122
 
123
+ /**
124
+ * Return the memoized value after computing it if necessary.
125
+ *
126
+ * @returns {T}
127
+ * @throws {CircularDependencyError} If a circular dependency is detected
128
+ * @throws {Error} If an error occurs during computation
129
+ */
130
+ get(): T {
131
+ subscribeTo(this)
132
+ flush()
133
+
134
+ if (this.#dirty) this.#getWatcher().run()
126
135
  if (this.#error) throw this.#error
127
136
  return this.#value
128
137
  }
@@ -132,43 +141,124 @@ class Memo<T extends {}> {
132
141
  * Create a new task signals that memoizes the result of an asynchronous function.
133
142
  *
134
143
  * @since 0.17.0
144
+ * @param {TaskCallback<T>} callback - The asynchronous function to compute the memoized value
145
+ * @param {T} [initialValue = UNSET] - Initial value of the signal
146
+ * @throws {InvalidCallbackError} If the callback is not an async function
147
+ * @throws {InvalidSignalValueError} If the initial value is not valid
135
148
  */
136
149
  class Task<T extends {}> {
137
- #watchers: Set<Watcher> = new Set()
138
150
  #callback: TaskCallback<T>
139
151
  #value: T
140
152
  #error: Error | undefined
141
153
  #dirty = true
142
154
  #computing = false
143
155
  #changed = false
144
- #watcher: Watcher
156
+ #watcher: Watcher | undefined
145
157
  #controller: AbortController | undefined
146
158
 
147
- /**
148
- * Create a new task signal for an asynchronous function.
149
- *
150
- * @param {TaskCallback<T>} callback - The asynchronous function to compute the memoized value
151
- * @param {T} [initialValue = UNSET] - Initial value of the signal
152
- * @throws {InvalidCallbackError} If the callback is not an async function
153
- * @throws {InvalidSignalValueError} If the initial value is not valid
154
- */
155
- constructor(callback: TaskCallback<T>, initialValue: T = UNSET) {
156
- validateCallback('task', callback, isTaskCallback)
157
- validateSignalValue('task', initialValue)
159
+ constructor(callback: TaskCallback<T>, options?: ComputedOptions<T>) {
160
+ validateCallback(this.constructor.name, callback, isTaskCallback)
161
+ const initialValue = options?.initialValue ?? UNSET
162
+ validateSignalValue(this.constructor.name, initialValue, options?.guard)
158
163
 
159
164
  this.#callback = callback
160
165
  this.#value = initialValue
166
+ if (options?.watched)
167
+ registerWatchCallbacks(this, options.watched, options.unwatched)
168
+ }
161
169
 
162
- // Own watcher: called by notifyWatchers() in upstream signals (push)
163
- this.#watcher = createWatcher(() => {
164
- this.#dirty = true
165
- this.#controller?.abort()
166
- if (this.#watchers.size) notifyWatchers(this.#watchers)
167
- else this.#watcher.stop()
168
- })
169
- this.#watcher.onCleanup(() => {
170
- this.#controller?.abort()
171
- })
170
+ #getWatcher(): Watcher {
171
+ if (!this.#watcher) {
172
+ // Functions to update internal state
173
+ const ok = (v: T): undefined => {
174
+ if (!isEqual(v, this.#value)) {
175
+ this.#value = v
176
+ this.#changed = true
177
+ }
178
+ this.#error = undefined
179
+ this.#dirty = false
180
+ }
181
+ const nil = (): undefined => {
182
+ this.#changed = UNSET !== this.#value
183
+ this.#value = UNSET
184
+ this.#error = undefined
185
+ }
186
+ const err = (e: unknown): undefined => {
187
+ const newError = createError(e)
188
+ this.#changed =
189
+ !this.#error ||
190
+ newError.name !== this.#error.name ||
191
+ newError.message !== this.#error.message
192
+ this.#value = UNSET
193
+ this.#error = newError
194
+ }
195
+ const settle =
196
+ <T>(fn: (arg: T) => void) =>
197
+ (arg: T) => {
198
+ this.#computing = false
199
+ this.#controller = undefined
200
+ fn(arg)
201
+ if (this.#changed && !notifyOf(this)) this.#watcher?.stop()
202
+ }
203
+
204
+ // Own watcher: called by notifyOf() in upstream signals (push)
205
+ this.#watcher = createWatcher(
206
+ () => {
207
+ this.#dirty = true
208
+ this.#controller?.abort()
209
+ if (!notifyOf(this)) this.#watcher?.stop()
210
+ },
211
+ () => {
212
+ if (this.#computing)
213
+ throw new CircularDependencyError('task')
214
+ this.#changed = false
215
+
216
+ // Return current value until promise resolves
217
+ if (this.#controller) return this.#value
218
+
219
+ this.#controller = new AbortController()
220
+ this.#controller.signal.addEventListener(
221
+ 'abort',
222
+ () => {
223
+ this.#computing = false
224
+ this.#controller = undefined
225
+
226
+ // Retry computation with updated state
227
+ this.#getWatcher().run()
228
+ },
229
+ {
230
+ once: true,
231
+ },
232
+ )
233
+ let result: Promise<T>
234
+ this.#computing = true
235
+ try {
236
+ result = this.#callback(
237
+ this.#value,
238
+ this.#controller.signal,
239
+ )
240
+ } catch (e) {
241
+ if (isAbortError(e)) nil()
242
+ else err(e)
243
+ this.#computing = false
244
+ return
245
+ }
246
+
247
+ if (result instanceof Promise)
248
+ result.then(settle(ok), settle(err))
249
+ else if (null == result || UNSET === result) nil()
250
+ else ok(result)
251
+ this.#computing = false
252
+ },
253
+ )
254
+ this.#watcher.onCleanup(() => {
255
+ this.#controller?.abort()
256
+ this.#controller = undefined
257
+ this.#watcher = undefined
258
+ })
259
+ }
260
+
261
+ return this.#watcher
172
262
  }
173
263
 
174
264
  get [Symbol.toStringTag](): 'Computed' {
@@ -183,86 +273,10 @@ class Task<T extends {}> {
183
273
  * @throws {Error} If an error occurs during computation
184
274
  */
185
275
  get(): T {
186
- subscribeActiveWatcher(this.#watchers)
187
- flushPendingReactions()
188
-
189
- // Functions to update internal state
190
- const ok = (v: T): undefined => {
191
- if (!isEqual(v, this.#value)) {
192
- this.#value = v
193
- this.#changed = true
194
- }
195
- this.#error = undefined
196
- this.#dirty = false
197
- }
198
- const nil = (): undefined => {
199
- this.#changed = UNSET !== this.#value
200
- this.#value = UNSET
201
- this.#error = undefined
202
- }
203
- const err = (e: unknown): undefined => {
204
- const newError = createError(e)
205
- this.#changed =
206
- !this.#error ||
207
- newError.name !== this.#error.name ||
208
- newError.message !== this.#error.message
209
- this.#value = UNSET
210
- this.#error = newError
211
- }
212
- const settle =
213
- <T>(fn: (arg: T) => void) =>
214
- (arg: T) => {
215
- this.#computing = false
216
- this.#controller = undefined
217
- fn(arg)
218
- if (this.#changed) notifyWatchers(this.#watchers)
219
- }
220
-
221
- const compute = () =>
222
- trackSignalReads(this.#watcher, () => {
223
- if (this.#computing) throw new CircularDependencyError('task')
224
- this.#changed = false
225
-
226
- // Return current value until promise resolves
227
- if (this.#controller) return this.#value
228
-
229
- this.#controller = new AbortController()
230
- this.#controller.signal.addEventListener(
231
- 'abort',
232
- () => {
233
- this.#computing = false
234
- this.#controller = undefined
235
-
236
- // Retry computation with updated state
237
- compute()
238
- },
239
- {
240
- once: true,
241
- },
242
- )
243
- let result: Promise<T>
244
- this.#computing = true
245
- try {
246
- result = this.#callback(
247
- this.#value,
248
- this.#controller.signal,
249
- )
250
- } catch (e) {
251
- if (isAbortError(e)) nil()
252
- else err(e)
253
- this.#computing = false
254
- return
255
- }
256
-
257
- if (result instanceof Promise)
258
- result.then(settle(ok), settle(err))
259
- else if (null == result || UNSET === result) nil()
260
- else ok(result)
261
- this.#computing = false
262
- })
263
-
264
- if (this.#dirty) compute()
276
+ subscribeTo(this)
277
+ flush()
265
278
 
279
+ if (this.#dirty) this.#getWatcher().run()
266
280
  if (this.#error) throw this.#error
267
281
  return this.#value
268
282
  }
@@ -275,14 +289,15 @@ class Task<T extends {}> {
275
289
  *
276
290
  * @since 0.9.0
277
291
  * @param {MemoCallback<T> | TaskCallback<T>} callback - Computation callback function
292
+ * @param {ComputedOptions<T>} options - Optional configuration
278
293
  */
279
294
  const createComputed = <T extends {}>(
280
295
  callback: TaskCallback<T> | MemoCallback<T>,
281
- initialValue: T = UNSET,
296
+ options?: ComputedOptions<T>,
282
297
  ) =>
283
298
  isAsyncFunction(callback)
284
- ? new Task(callback as TaskCallback<T>, initialValue)
285
- : new Memo(callback as MemoCallback<T>, initialValue)
299
+ ? new Task(callback as TaskCallback<T>, options)
300
+ : new Memo(callback as MemoCallback<T>, options)
286
301
 
287
302
  /**
288
303
  * Check if a value is a computed signal
@@ -328,6 +343,7 @@ export {
328
343
  Memo,
329
344
  Task,
330
345
  type Computed,
346
+ type ComputedOptions,
331
347
  type MemoCallback,
332
348
  type TaskCallback,
333
349
  }