@zeix/cause-effect 0.17.2 → 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 (50) hide show
  1. package/.ai-context.md +11 -5
  2. package/.github/copilot-instructions.md +1 -1
  3. package/.zed/settings.json +3 -0
  4. package/CLAUDE.md +18 -79
  5. package/README.md +23 -37
  6. package/archive/benchmark.ts +0 -5
  7. package/archive/collection.ts +5 -62
  8. package/archive/composite.ts +85 -0
  9. package/archive/computed.ts +17 -20
  10. package/archive/list.ts +6 -67
  11. package/archive/memo.ts +13 -14
  12. package/archive/store.ts +7 -66
  13. package/archive/task.ts +18 -20
  14. package/index.dev.js +438 -614
  15. package/index.js +1 -1
  16. package/index.ts +8 -19
  17. package/package.json +6 -6
  18. package/src/classes/collection.ts +59 -112
  19. package/src/classes/computed.ts +146 -189
  20. package/src/classes/list.ts +138 -105
  21. package/src/classes/ref.ts +16 -42
  22. package/src/classes/state.ts +16 -45
  23. package/src/classes/store.ts +107 -72
  24. package/src/effect.ts +9 -12
  25. package/src/errors.ts +12 -8
  26. package/src/signal.ts +3 -1
  27. package/src/system.ts +136 -154
  28. package/test/batch.test.ts +4 -11
  29. package/test/benchmark.test.ts +4 -2
  30. package/test/collection.test.ts +46 -306
  31. package/test/computed.test.ts +205 -223
  32. package/test/list.test.ts +35 -303
  33. package/test/ref.test.ts +38 -66
  34. package/test/state.test.ts +6 -12
  35. package/test/store.test.ts +37 -489
  36. package/test/util/dependency-graph.ts +2 -2
  37. package/tsconfig.build.json +11 -0
  38. package/tsconfig.json +5 -7
  39. package/types/index.d.ts +2 -2
  40. package/types/src/classes/collection.d.ts +4 -6
  41. package/types/src/classes/computed.d.ts +17 -37
  42. package/types/src/classes/list.d.ts +8 -6
  43. package/types/src/classes/ref.d.ts +7 -20
  44. package/types/src/classes/state.d.ts +5 -17
  45. package/types/src/classes/store.d.ts +12 -11
  46. package/types/src/errors.d.ts +2 -4
  47. package/types/src/signal.d.ts +3 -2
  48. package/types/src/system.d.ts +41 -44
  49. package/src/classes/composite.ts +0 -171
  50. package/types/src/classes/composite.d.ts +0 -15
@@ -2,23 +2,18 @@ import { isEqual } from '../diff'
2
2
  import {
3
3
  CircularDependencyError,
4
4
  createError,
5
- InvalidHookError,
6
5
  validateCallback,
7
6
  validateSignalValue,
8
7
  } from '../errors'
9
8
  import {
10
- type Cleanup,
11
9
  createWatcher,
12
- flushPendingReactions,
13
- HOOK_CLEANUP,
14
- HOOK_WATCH,
15
- type HookCallback,
16
- notifyWatchers,
17
- subscribeActiveWatcher,
18
- trackSignalReads,
10
+ flush,
11
+ notifyOf,
12
+ registerWatchCallbacks,
13
+ type SignalOptions,
14
+ subscribeTo,
19
15
  UNSET,
20
16
  type Watcher,
21
- type WatchHook,
22
17
  } from '../system'
23
18
  import {
24
19
  isAbortError,
@@ -34,6 +29,10 @@ type Computed<T extends {}> = {
34
29
  get(): T
35
30
  }
36
31
 
32
+ type ComputedOptions<T extends {}> = SignalOptions<T> & {
33
+ initialValue?: T
34
+ }
35
+
37
36
  type MemoCallback<T extends {} & { then?: undefined }> = (oldValue: T) => T
38
37
 
39
38
  type TaskCallback<T extends {} & { then?: undefined }> = (
@@ -51,65 +50,38 @@ const TYPE_COMPUTED = 'Computed' as const
51
50
  * Create a new memoized signal for a synchronous function.
52
51
  *
53
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
54
57
  */
55
58
  class Memo<T extends {}> {
56
- #watchers: Set<Watcher> = new Set()
57
59
  #callback: MemoCallback<T>
58
60
  #value: T
59
61
  #error: Error | undefined
60
62
  #dirty = true
61
63
  #computing = false
62
64
  #watcher: Watcher | undefined
63
- #watchHookCallbacks: Set<HookCallback> | undefined
64
65
 
65
- /**
66
- * Create a new memoized signal.
67
- *
68
- * @param {MemoCallback<T>} callback - Callback function to compute the memoized value
69
- * @param {T} [initialValue = UNSET] - Initial value of the signal
70
- * @throws {InvalidCallbackError} If the callback is not an sync function
71
- * @throws {InvalidSignalValueError} If the initial value is not valid
72
- */
73
- constructor(callback: MemoCallback<T>, initialValue: T = UNSET) {
66
+ constructor(callback: MemoCallback<T>, options?: ComputedOptions<T>) {
74
67
  validateCallback(this.constructor.name, callback, isMemoCallback)
75
- validateSignalValue(this.constructor.name, initialValue)
68
+ const initialValue = options?.initialValue ?? UNSET
69
+ validateSignalValue(this.constructor.name, initialValue, options?.guard)
76
70
 
77
71
  this.#callback = callback
78
72
  this.#value = initialValue
73
+ if (options?.watched)
74
+ registerWatchCallbacks(this, options.watched, options.unwatched)
79
75
  }
80
76
 
81
77
  #getWatcher(): Watcher {
82
- if (!this.#watcher) {
83
- // Own watcher: called by notifyWatchers() in upstream signals (push)
84
- this.#watcher = createWatcher(() => {
78
+ // Own watcher: called by notifyWatchers() in upstream signals (push)
79
+ this.#watcher ||= createWatcher(
80
+ () => {
85
81
  this.#dirty = true
86
- if (!notifyWatchers(this.#watchers)) this.#watcher?.stop()
87
- })
88
- this.#watcher.on(HOOK_CLEANUP, () => {
89
- this.#watcher = undefined
90
- })
91
- }
92
- return this.#watcher
93
- }
94
-
95
- get [Symbol.toStringTag](): 'Computed' {
96
- return TYPE_COMPUTED
97
- }
98
-
99
- /**
100
- * Return the memoized value after computing it if necessary.
101
- *
102
- * @returns {T}
103
- * @throws {CircularDependencyError} If a circular dependency is detected
104
- * @throws {Error} If an error occurs during computation
105
- */
106
- get(): T {
107
- subscribeActiveWatcher(this.#watchers, this.#watchHookCallbacks)
108
- flushPendingReactions()
109
-
110
- if (this.#dirty) {
111
- const watcher = this.#getWatcher()
112
- trackSignalReads(watcher, () => {
82
+ if (!notifyOf(this)) this.#watcher?.stop()
83
+ },
84
+ () => {
113
85
  if (this.#computing) throw new CircularDependencyError('memo')
114
86
 
115
87
  let result: T
@@ -135,29 +107,33 @@ class Memo<T extends {}> {
135
107
  this.#dirty = false
136
108
  }
137
109
  this.#computing = false
138
- })
139
- }
110
+ },
111
+ )
112
+ this.#watcher.onCleanup(() => {
113
+ this.#watcher = undefined
114
+ })
140
115
 
141
- if (this.#error) throw this.#error
142
- return this.#value
116
+ return this.#watcher
117
+ }
118
+
119
+ get [Symbol.toStringTag](): 'Computed' {
120
+ return TYPE_COMPUTED
143
121
  }
144
122
 
145
123
  /**
146
- * Register a callback to be called when HOOK_WATCH is triggered.
124
+ * Return the memoized value after computing it if necessary.
147
125
  *
148
- * @param {WatchHook} type - The type of hook to register the callback for; only HOOK_WATCH is supported
149
- * @param {HookCallback} callback - The callback to register
150
- * @returns {Cleanup} - A function to unregister the callback
126
+ * @returns {T}
127
+ * @throws {CircularDependencyError} If a circular dependency is detected
128
+ * @throws {Error} If an error occurs during computation
151
129
  */
152
- on(type: WatchHook, callback: HookCallback): Cleanup {
153
- if (type === HOOK_WATCH) {
154
- this.#watchHookCallbacks ||= new Set()
155
- this.#watchHookCallbacks.add(callback)
156
- return () => {
157
- this.#watchHookCallbacks?.delete(callback)
158
- }
159
- }
160
- throw new InvalidHookError(this.constructor.name, type)
130
+ get(): T {
131
+ subscribeTo(this)
132
+ flush()
133
+
134
+ if (this.#dirty) this.#getWatcher().run()
135
+ if (this.#error) throw this.#error
136
+ return this.#value
161
137
  }
162
138
  }
163
139
 
@@ -165,9 +141,12 @@ class Memo<T extends {}> {
165
141
  * Create a new task signals that memoizes the result of an asynchronous function.
166
142
  *
167
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
168
148
  */
169
149
  class Task<T extends {}> {
170
- #watchers: Set<Watcher> = new Set()
171
150
  #callback: TaskCallback<T>
172
151
  #value: T
173
152
  #error: Error | undefined
@@ -176,38 +155,109 @@ class Task<T extends {}> {
176
155
  #changed = false
177
156
  #watcher: Watcher | undefined
178
157
  #controller: AbortController | undefined
179
- #watchHookCallbacks: Set<HookCallback> | undefined
180
158
 
181
- /**
182
- * Create a new task signal for an asynchronous function.
183
- *
184
- * @param {TaskCallback<T>} callback - The asynchronous function to compute the memoized value
185
- * @param {T} [initialValue = UNSET] - Initial value of the signal
186
- * @throws {InvalidCallbackError} If the callback is not an async function
187
- * @throws {InvalidSignalValueError} If the initial value is not valid
188
- */
189
- constructor(callback: TaskCallback<T>, initialValue: T = UNSET) {
159
+ constructor(callback: TaskCallback<T>, options?: ComputedOptions<T>) {
190
160
  validateCallback(this.constructor.name, callback, isTaskCallback)
191
- validateSignalValue(this.constructor.name, initialValue)
161
+ const initialValue = options?.initialValue ?? UNSET
162
+ validateSignalValue(this.constructor.name, initialValue, options?.guard)
192
163
 
193
164
  this.#callback = callback
194
165
  this.#value = initialValue
166
+ if (options?.watched)
167
+ registerWatchCallbacks(this, options.watched, options.unwatched)
195
168
  }
196
169
 
197
170
  #getWatcher(): Watcher {
198
171
  if (!this.#watcher) {
199
- // Own watcher: called by notifyWatchers() in upstream signals (push)
200
- this.#watcher = createWatcher(() => {
201
- this.#dirty = true
202
- this.#controller?.abort()
203
- if (!notifyWatchers(this.#watchers)) this.#watcher?.stop()
204
- })
205
- this.#watcher.on(HOOK_CLEANUP, () => {
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(() => {
206
255
  this.#controller?.abort()
207
256
  this.#controller = undefined
208
257
  this.#watcher = undefined
209
258
  })
210
259
  }
260
+
211
261
  return this.#watcher
212
262
  }
213
263
 
@@ -223,108 +273,13 @@ class Task<T extends {}> {
223
273
  * @throws {Error} If an error occurs during computation
224
274
  */
225
275
  get(): T {
226
- subscribeActiveWatcher(this.#watchers, this.#watchHookCallbacks)
227
- flushPendingReactions()
228
-
229
- // Functions to update internal state
230
- const ok = (v: T): undefined => {
231
- if (!isEqual(v, this.#value)) {
232
- this.#value = v
233
- this.#changed = true
234
- }
235
- this.#error = undefined
236
- this.#dirty = false
237
- }
238
- const nil = (): undefined => {
239
- this.#changed = UNSET !== this.#value
240
- this.#value = UNSET
241
- this.#error = undefined
242
- }
243
- const err = (e: unknown): undefined => {
244
- const newError = createError(e)
245
- this.#changed =
246
- !this.#error ||
247
- newError.name !== this.#error.name ||
248
- newError.message !== this.#error.message
249
- this.#value = UNSET
250
- this.#error = newError
251
- }
252
- const settle =
253
- <T>(fn: (arg: T) => void) =>
254
- (arg: T) => {
255
- this.#computing = false
256
- this.#controller = undefined
257
- fn(arg)
258
- if (this.#changed && !notifyWatchers(this.#watchers))
259
- this.#watcher?.stop()
260
- }
261
-
262
- const compute = () =>
263
- trackSignalReads(this.#getWatcher(), () => {
264
- if (this.#computing) throw new CircularDependencyError('task')
265
- this.#changed = false
266
-
267
- // Return current value until promise resolves
268
- if (this.#controller) return this.#value
269
-
270
- this.#controller = new AbortController()
271
- this.#controller.signal.addEventListener(
272
- 'abort',
273
- () => {
274
- this.#computing = false
275
- this.#controller = undefined
276
-
277
- // Retry computation with updated state
278
- compute()
279
- },
280
- {
281
- once: true,
282
- },
283
- )
284
- let result: Promise<T>
285
- this.#computing = true
286
- try {
287
- result = this.#callback(
288
- this.#value,
289
- this.#controller.signal,
290
- )
291
- } catch (e) {
292
- if (isAbortError(e)) nil()
293
- else err(e)
294
- this.#computing = false
295
- return
296
- }
297
-
298
- if (result instanceof Promise)
299
- result.then(settle(ok), settle(err))
300
- else if (null == result || UNSET === result) nil()
301
- else ok(result)
302
- this.#computing = false
303
- })
304
-
305
- if (this.#dirty) compute()
276
+ subscribeTo(this)
277
+ flush()
306
278
 
279
+ if (this.#dirty) this.#getWatcher().run()
307
280
  if (this.#error) throw this.#error
308
281
  return this.#value
309
282
  }
310
-
311
- /**
312
- * Register a callback to be called when HOOK_WATCH is triggered.
313
- *
314
- * @param {WatchHook} type - The type of hook to register the callback for; only HOOK_WATCH is supported
315
- * @param {HookCallback} callback - The callback to register
316
- * @returns {Cleanup} - A function to unregister the callback
317
- */
318
- on(type: WatchHook, callback: HookCallback): Cleanup {
319
- if (type === HOOK_WATCH) {
320
- this.#watchHookCallbacks ||= new Set()
321
- this.#watchHookCallbacks.add(callback)
322
- return () => {
323
- this.#watchHookCallbacks?.delete(callback)
324
- }
325
- }
326
- throw new InvalidHookError(this.constructor.name, type)
327
- }
328
283
  }
329
284
 
330
285
  /* === Functions === */
@@ -334,14 +289,15 @@ class Task<T extends {}> {
334
289
  *
335
290
  * @since 0.9.0
336
291
  * @param {MemoCallback<T> | TaskCallback<T>} callback - Computation callback function
292
+ * @param {ComputedOptions<T>} options - Optional configuration
337
293
  */
338
294
  const createComputed = <T extends {}>(
339
295
  callback: TaskCallback<T> | MemoCallback<T>,
340
- initialValue: T = UNSET,
296
+ options?: ComputedOptions<T>,
341
297
  ) =>
342
298
  isAsyncFunction(callback)
343
- ? new Task(callback as TaskCallback<T>, initialValue)
344
- : new Memo(callback as MemoCallback<T>, initialValue)
299
+ ? new Task(callback as TaskCallback<T>, options)
300
+ : new Memo(callback as MemoCallback<T>, options)
345
301
 
346
302
  /**
347
303
  * Check if a value is a computed signal
@@ -387,6 +343,7 @@ export {
387
343
  Memo,
388
344
  Task,
389
345
  type Computed,
346
+ type ComputedOptions,
390
347
  type MemoCallback,
391
348
  type TaskCallback,
392
349
  }