@zeix/cause-effect 0.17.0 → 0.17.2

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 +26 -5
  2. package/.cursorrules +8 -3
  3. package/.github/copilot-instructions.md +13 -4
  4. package/CLAUDE.md +191 -262
  5. package/README.md +268 -420
  6. package/archive/collection.ts +23 -25
  7. package/archive/computed.ts +5 -4
  8. package/archive/list.ts +21 -28
  9. package/archive/memo.ts +4 -2
  10. package/archive/state.ts +2 -1
  11. package/archive/store.ts +21 -32
  12. package/archive/task.ts +6 -9
  13. package/index.dev.js +411 -220
  14. package/index.js +1 -1
  15. package/index.ts +25 -8
  16. package/package.json +1 -1
  17. package/src/classes/collection.ts +103 -77
  18. package/src/classes/composite.ts +28 -33
  19. package/src/classes/computed.ts +90 -31
  20. package/src/classes/list.ts +39 -33
  21. package/src/classes/ref.ts +96 -0
  22. package/src/classes/state.ts +41 -8
  23. package/src/classes/store.ts +47 -30
  24. package/src/diff.ts +2 -1
  25. package/src/effect.ts +19 -9
  26. package/src/errors.ts +31 -1
  27. package/src/match.ts +5 -12
  28. package/src/resolve.ts +3 -2
  29. package/src/signal.ts +0 -1
  30. package/src/system.ts +159 -43
  31. package/src/util.ts +0 -10
  32. package/test/collection.test.ts +383 -67
  33. package/test/computed.test.ts +268 -11
  34. package/test/effect.test.ts +2 -2
  35. package/test/list.test.ts +249 -21
  36. package/test/ref.test.ts +381 -0
  37. package/test/state.test.ts +13 -13
  38. package/test/store.test.ts +473 -28
  39. package/types/index.d.ts +6 -5
  40. package/types/src/classes/collection.d.ts +27 -12
  41. package/types/src/classes/composite.d.ts +4 -4
  42. package/types/src/classes/computed.d.ts +17 -0
  43. package/types/src/classes/list.d.ts +6 -6
  44. package/types/src/classes/ref.d.ts +48 -0
  45. package/types/src/classes/state.d.ts +9 -0
  46. package/types/src/classes/store.d.ts +4 -4
  47. package/types/src/effect.d.ts +1 -2
  48. package/types/src/errors.d.ts +9 -1
  49. package/types/src/system.d.ts +40 -24
  50. package/types/src/util.d.ts +1 -3
@@ -1,24 +1,30 @@
1
1
  import { isEqual } from '../diff'
2
2
  import {
3
3
  CircularDependencyError,
4
+ createError,
5
+ InvalidHookError,
4
6
  validateCallback,
5
7
  validateSignalValue,
6
8
  } from '../errors'
7
9
  import {
10
+ type Cleanup,
8
11
  createWatcher,
9
12
  flushPendingReactions,
13
+ HOOK_CLEANUP,
14
+ HOOK_WATCH,
15
+ type HookCallback,
10
16
  notifyWatchers,
11
17
  subscribeActiveWatcher,
12
18
  trackSignalReads,
19
+ UNSET,
13
20
  type Watcher,
21
+ type WatchHook,
14
22
  } from '../system'
15
23
  import {
16
24
  isAbortError,
17
25
  isAsyncFunction,
18
26
  isObjectOfType,
19
27
  isSyncFunction,
20
- toError,
21
- UNSET,
22
28
  } from '../util'
23
29
 
24
30
  /* === Types === */
@@ -53,7 +59,8 @@ class Memo<T extends {}> {
53
59
  #error: Error | undefined
54
60
  #dirty = true
55
61
  #computing = false
56
- #watcher: Watcher
62
+ #watcher: Watcher | undefined
63
+ #watchHookCallbacks: Set<HookCallback> | undefined
57
64
 
58
65
  /**
59
66
  * Create a new memoized signal.
@@ -64,18 +71,25 @@ class Memo<T extends {}> {
64
71
  * @throws {InvalidSignalValueError} If the initial value is not valid
65
72
  */
66
73
  constructor(callback: MemoCallback<T>, initialValue: T = UNSET) {
67
- validateCallback('memo', callback, isMemoCallback)
68
- validateSignalValue('memo', initialValue)
74
+ validateCallback(this.constructor.name, callback, isMemoCallback)
75
+ validateSignalValue(this.constructor.name, initialValue)
69
76
 
70
77
  this.#callback = callback
71
78
  this.#value = initialValue
79
+ }
72
80
 
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
- })
81
+ #getWatcher(): Watcher {
82
+ if (!this.#watcher) {
83
+ // Own watcher: called by notifyWatchers() in upstream signals (push)
84
+ this.#watcher = createWatcher(() => {
85
+ 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
79
93
  }
80
94
 
81
95
  get [Symbol.toStringTag](): 'Computed' {
@@ -90,11 +104,12 @@ class Memo<T extends {}> {
90
104
  * @throws {Error} If an error occurs during computation
91
105
  */
92
106
  get(): T {
93
- subscribeActiveWatcher(this.#watchers)
107
+ subscribeActiveWatcher(this.#watchers, this.#watchHookCallbacks)
94
108
  flushPendingReactions()
95
109
 
96
110
  if (this.#dirty) {
97
- trackSignalReads(this.#watcher, () => {
111
+ const watcher = this.#getWatcher()
112
+ trackSignalReads(watcher, () => {
98
113
  if (this.#computing) throw new CircularDependencyError('memo')
99
114
 
100
115
  let result: T
@@ -104,7 +119,7 @@ class Memo<T extends {}> {
104
119
  } catch (e) {
105
120
  // Err track
106
121
  this.#value = UNSET
107
- this.#error = toError(e)
122
+ this.#error = createError(e)
108
123
  this.#computing = false
109
124
  return
110
125
  }
@@ -126,6 +141,24 @@ class Memo<T extends {}> {
126
141
  if (this.#error) throw this.#error
127
142
  return this.#value
128
143
  }
144
+
145
+ /**
146
+ * Register a callback to be called when HOOK_WATCH is triggered.
147
+ *
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
151
+ */
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)
161
+ }
129
162
  }
130
163
 
131
164
  /**
@@ -141,8 +174,9 @@ class Task<T extends {}> {
141
174
  #dirty = true
142
175
  #computing = false
143
176
  #changed = false
144
- #watcher: Watcher
177
+ #watcher: Watcher | undefined
145
178
  #controller: AbortController | undefined
179
+ #watchHookCallbacks: Set<HookCallback> | undefined
146
180
 
147
181
  /**
148
182
  * Create a new task signal for an asynchronous function.
@@ -153,22 +187,28 @@ class Task<T extends {}> {
153
187
  * @throws {InvalidSignalValueError} If the initial value is not valid
154
188
  */
155
189
  constructor(callback: TaskCallback<T>, initialValue: T = UNSET) {
156
- validateCallback('task', callback, isTaskCallback)
157
- validateSignalValue('task', initialValue)
190
+ validateCallback(this.constructor.name, callback, isTaskCallback)
191
+ validateSignalValue(this.constructor.name, initialValue)
158
192
 
159
193
  this.#callback = callback
160
194
  this.#value = initialValue
195
+ }
161
196
 
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
- })
197
+ #getWatcher(): Watcher {
198
+ 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, () => {
206
+ this.#controller?.abort()
207
+ this.#controller = undefined
208
+ this.#watcher = undefined
209
+ })
210
+ }
211
+ return this.#watcher
172
212
  }
173
213
 
174
214
  get [Symbol.toStringTag](): 'Computed' {
@@ -183,7 +223,7 @@ class Task<T extends {}> {
183
223
  * @throws {Error} If an error occurs during computation
184
224
  */
185
225
  get(): T {
186
- subscribeActiveWatcher(this.#watchers)
226
+ subscribeActiveWatcher(this.#watchers, this.#watchHookCallbacks)
187
227
  flushPendingReactions()
188
228
 
189
229
  // Functions to update internal state
@@ -201,7 +241,7 @@ class Task<T extends {}> {
201
241
  this.#error = undefined
202
242
  }
203
243
  const err = (e: unknown): undefined => {
204
- const newError = toError(e)
244
+ const newError = createError(e)
205
245
  this.#changed =
206
246
  !this.#error ||
207
247
  newError.name !== this.#error.name ||
@@ -215,11 +255,12 @@ class Task<T extends {}> {
215
255
  this.#computing = false
216
256
  this.#controller = undefined
217
257
  fn(arg)
218
- if (this.#changed) notifyWatchers(this.#watchers)
258
+ if (this.#changed && !notifyWatchers(this.#watchers))
259
+ this.#watcher?.stop()
219
260
  }
220
261
 
221
262
  const compute = () =>
222
- trackSignalReads(this.#watcher, () => {
263
+ trackSignalReads(this.#getWatcher(), () => {
223
264
  if (this.#computing) throw new CircularDependencyError('task')
224
265
  this.#changed = false
225
266
 
@@ -266,6 +307,24 @@ class Task<T extends {}> {
266
307
  if (this.#error) throw this.#error
267
308
  return this.#value
268
309
  }
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
+ }
269
328
  }
270
329
 
271
330
  /* === Functions === */
@@ -1,17 +1,28 @@
1
1
  import { diff, isEqual, type UnknownArray } from '../diff'
2
- import { DuplicateKeyError, validateSignalValue } from '../errors'
2
+ import {
3
+ DuplicateKeyError,
4
+ InvalidHookError,
5
+ validateSignalValue,
6
+ } from '../errors'
3
7
  import {
4
8
  type Cleanup,
5
- emitNotification,
6
- type Listener,
7
- type Listeners,
8
- type Notifications,
9
+ HOOK_ADD,
10
+ HOOK_CHANGE,
11
+ HOOK_REMOVE,
12
+ HOOK_SORT,
13
+ HOOK_WATCH,
14
+ type Hook,
15
+ type HookCallback,
16
+ type HookCallbacks,
17
+ isHandledHook,
9
18
  notifyWatchers,
10
19
  subscribeActiveWatcher,
20
+ triggerHook,
21
+ UNSET,
11
22
  type Watcher,
12
23
  } from '../system'
13
- import { isFunction, isNumber, isObjectOfType, isString, UNSET } from '../util'
14
- import { Collection, type CollectionCallback } from './collection'
24
+ import { isFunction, isNumber, isObjectOfType, isString } from '../util'
25
+ import { type CollectionCallback, DerivedCollection } from './collection'
15
26
  import { Composite } from './composite'
16
27
  import { State } from './state'
17
28
 
@@ -32,14 +43,12 @@ const TYPE_LIST = 'List' as const
32
43
  class List<T extends {}> {
33
44
  #composite: Composite<Record<string, T>, State<T>>
34
45
  #watchers = new Set<Watcher>()
35
- #listeners: Pick<Listeners, 'sort'> = {
36
- sort: new Set<Listener<'sort'>>(),
37
- }
46
+ #hookCallbacks: HookCallbacks = {}
38
47
  #order: string[] = []
39
48
  #generateKey: (item: T) => string
40
49
 
41
50
  constructor(initialValue: T[], keyConfig?: KeyConfig<T>) {
42
- validateSignalValue('list', initialValue, Array.isArray)
51
+ validateSignalValue(TYPE_LIST, initialValue, Array.isArray)
43
52
 
44
53
  let keyCounter = 0
45
54
  this.#generateKey = isString(keyConfig)
@@ -51,7 +60,7 @@ class List<T extends {}> {
51
60
  this.#composite = new Composite<ArrayToRecord<T[]>, State<T>>(
52
61
  this.#toRecord(initialValue),
53
62
  (key: string, value: unknown): value is T => {
54
- validateSignalValue(`list for key "${key}"`, value)
63
+ validateSignalValue(`${TYPE_LIST} for key "${key}"`, value)
55
64
  return true
56
65
  },
57
66
  value => new State(value),
@@ -87,7 +96,7 @@ class List<T extends {}> {
87
96
  return TYPE_LIST
88
97
  }
89
98
 
90
- get [Symbol.isConcatSpreadable](): boolean {
99
+ get [Symbol.isConcatSpreadable](): true {
91
100
  return true
92
101
  }
93
102
 
@@ -99,12 +108,12 @@ class List<T extends {}> {
99
108
  }
100
109
 
101
110
  get length(): number {
102
- subscribeActiveWatcher(this.#watchers)
111
+ subscribeActiveWatcher(this.#watchers, this.#hookCallbacks[HOOK_WATCH])
103
112
  return this.#order.length
104
113
  }
105
114
 
106
115
  get(): T[] {
107
- subscribeActiveWatcher(this.#watchers)
116
+ subscribeActiveWatcher(this.#watchers, this.#hookCallbacks[HOOK_WATCH])
108
117
  return this.#value
109
118
  }
110
119
 
@@ -198,7 +207,7 @@ class List<T extends {}> {
198
207
  if (!isEqual(this.#order, newOrder)) {
199
208
  this.#order = newOrder
200
209
  notifyWatchers(this.#watchers)
201
- emitNotification(this.#listeners.sort, this.#order)
210
+ triggerHook(this.#hookCallbacks.sort, this.#order)
202
211
  }
203
212
  }
204
213
 
@@ -258,32 +267,29 @@ class List<T extends {}> {
258
267
  return Object.values(remove)
259
268
  }
260
269
 
261
- on<K extends keyof Notifications>(type: K, listener: Listener<K>): Cleanup {
262
- if (type === 'sort') {
263
- this.#listeners.sort.add(listener as Listener<'sort'>)
264
- return () =>
265
- this.#listeners.sort.delete(listener as Listener<'sort'>)
270
+ on(type: Hook, callback: HookCallback): Cleanup {
271
+ if (isHandledHook(type, [HOOK_SORT, HOOK_WATCH])) {
272
+ this.#hookCallbacks[type] ||= new Set<HookCallback>()
273
+ this.#hookCallbacks[type].add(callback)
274
+ return () => {
275
+ this.#hookCallbacks[type]?.delete(callback)
276
+ }
277
+ } else if (isHandledHook(type, [HOOK_ADD, HOOK_CHANGE, HOOK_REMOVE])) {
278
+ return this.#composite.on(type, callback)
266
279
  }
267
-
268
- // For other types, delegate to the composite
269
- return this.#composite.on(
270
- type,
271
- listener as Listener<
272
- keyof Pick<Notifications, 'add' | 'remove' | 'change'>
273
- >,
274
- )
280
+ throw new InvalidHookError(TYPE_LIST, type)
275
281
  }
276
282
 
277
283
  deriveCollection<R extends {}>(
278
284
  callback: (sourceValue: T) => R,
279
- ): Collection<R, T>
285
+ ): DerivedCollection<R, T>
280
286
  deriveCollection<R extends {}>(
281
287
  callback: (sourceValue: T, abort: AbortSignal) => Promise<R>,
282
- ): Collection<R, T>
288
+ ): DerivedCollection<R, T>
283
289
  deriveCollection<R extends {}>(
284
290
  callback: CollectionCallback<R, T>,
285
- ): Collection<R, T> {
286
- return new Collection(this, callback)
291
+ ): DerivedCollection<R, T> {
292
+ return new DerivedCollection(this, callback)
287
293
  }
288
294
  }
289
295
 
@@ -0,0 +1,96 @@
1
+ import { type Guard, InvalidHookError, validateSignalValue } from '../errors'
2
+ import {
3
+ type Cleanup,
4
+ HOOK_WATCH,
5
+ type HookCallback,
6
+ notifyWatchers,
7
+ subscribeActiveWatcher,
8
+ type Watcher,
9
+ type WatchHook,
10
+ } from '../system'
11
+ import { isObjectOfType } from '../util'
12
+
13
+ /* === Constants === */
14
+
15
+ const TYPE_REF = 'Ref'
16
+
17
+ /* === Class === */
18
+
19
+ /**
20
+ * Create a new ref signal.
21
+ *
22
+ * @since 0.17.1
23
+ */
24
+ class Ref<T extends {}> {
25
+ #watchers = new Set<Watcher>()
26
+ #value: T
27
+ #watchHookCallbacks: Set<HookCallback> | undefined
28
+
29
+ /**
30
+ * Create a new ref signal.
31
+ *
32
+ * @param {T} value - Reference to external object
33
+ * @param {Guard<T>} guard - Optional guard function to validate the value
34
+ * @throws {NullishSignalValueError} - If the value is null or undefined
35
+ * @throws {InvalidSignalValueError} - If the value is invalid
36
+ */
37
+ constructor(value: T, guard?: Guard<T>) {
38
+ validateSignalValue(TYPE_REF, value, guard)
39
+
40
+ this.#value = value
41
+ }
42
+
43
+ get [Symbol.toStringTag](): string {
44
+ return TYPE_REF
45
+ }
46
+
47
+ /**
48
+ * Get the value of the ref signal.
49
+ *
50
+ * @returns {T} - Object reference
51
+ */
52
+ get(): T {
53
+ subscribeActiveWatcher(this.#watchers, this.#watchHookCallbacks)
54
+
55
+ return this.#value
56
+ }
57
+
58
+ /**
59
+ * Notify watchers of relevant changes in the external reference.
60
+ */
61
+ notify(): void {
62
+ notifyWatchers(this.#watchers)
63
+ }
64
+
65
+ /**
66
+ * Register a callback to be called when HOOK_WATCH is triggered.
67
+ *
68
+ * @param {WatchHook} type - The type of hook to register the callback for; only HOOK_WATCH is supported
69
+ * @param {HookCallback} callback - The callback to register
70
+ * @returns {Cleanup} - A function to unregister the callback
71
+ */
72
+ on(type: WatchHook, callback: HookCallback): Cleanup {
73
+ if (type === HOOK_WATCH) {
74
+ this.#watchHookCallbacks ||= new Set()
75
+ this.#watchHookCallbacks.add(callback)
76
+ return () => {
77
+ this.#watchHookCallbacks?.delete(callback)
78
+ }
79
+ }
80
+ throw new InvalidHookError(TYPE_REF, type)
81
+ }
82
+ }
83
+
84
+ /* === Functions === */
85
+
86
+ /**
87
+ * Check if the provided value is a Ref instance
88
+ *
89
+ * @since 0.17.1
90
+ * @param {unknown} value - Value to check
91
+ * @returns {boolean} - True if the value is a Ref instance, false otherwise
92
+ */
93
+ const isRef = /*#__PURE__*/ <T extends {}>(value: unknown): value is Ref<T> =>
94
+ isObjectOfType(value, TYPE_REF)
95
+
96
+ export { TYPE_REF, Ref, isRef }
@@ -1,7 +1,20 @@
1
1
  import { isEqual } from '../diff'
2
- import { validateCallback, validateSignalValue } from '../errors'
3
- import { notifyWatchers, subscribeActiveWatcher, type Watcher } from '../system'
4
- import { isObjectOfType, UNSET } from '../util'
2
+ import {
3
+ InvalidHookError,
4
+ validateCallback,
5
+ validateSignalValue,
6
+ } from '../errors'
7
+ import {
8
+ type Cleanup,
9
+ HOOK_WATCH,
10
+ type HookCallback,
11
+ notifyWatchers,
12
+ subscribeActiveWatcher,
13
+ UNSET,
14
+ type Watcher,
15
+ type WatchHook,
16
+ } from '../system'
17
+ import { isObjectOfType } from '../util'
5
18
 
6
19
  /* === Constants === */
7
20
 
@@ -17,6 +30,7 @@ const TYPE_STATE = 'State' as const
17
30
  class State<T extends {}> {
18
31
  #watchers = new Set<Watcher>()
19
32
  #value: T
33
+ #watchHookCallbacks: Set<HookCallback> | undefined
20
34
 
21
35
  /**
22
36
  * Create a new state signal.
@@ -26,7 +40,7 @@ class State<T extends {}> {
26
40
  * @throws {InvalidSignalValueError} - If the initial value is invalid
27
41
  */
28
42
  constructor(initialValue: T) {
29
- validateSignalValue('state', initialValue)
43
+ validateSignalValue(TYPE_STATE, initialValue)
30
44
 
31
45
  this.#value = initialValue
32
46
  }
@@ -41,7 +55,8 @@ class State<T extends {}> {
41
55
  * @returns {T} - Current value of the state
42
56
  */
43
57
  get(): T {
44
- subscribeActiveWatcher(this.#watchers)
58
+ subscribeActiveWatcher(this.#watchers, this.#watchHookCallbacks)
59
+
45
60
  return this.#value
46
61
  }
47
62
 
@@ -54,11 +69,11 @@ class State<T extends {}> {
54
69
  * @throws {InvalidSignalValueError} - If the initial value is invalid
55
70
  */
56
71
  set(newValue: T): void {
57
- validateSignalValue('state', newValue)
72
+ validateSignalValue(TYPE_STATE, newValue)
58
73
 
59
74
  if (isEqual(this.#value, newValue)) return
60
75
  this.#value = newValue
61
- notifyWatchers(this.#watchers)
76
+ if (this.#watchers.size) notifyWatchers(this.#watchers)
62
77
 
63
78
  // Setting to UNSET clears the watchers so the signal can be garbage collected
64
79
  if (UNSET === this.#value) this.#watchers.clear()
@@ -74,10 +89,28 @@ class State<T extends {}> {
74
89
  * @throws {InvalidSignalValueError} - If the initial value is invalid
75
90
  */
76
91
  update(updater: (oldValue: T) => T): void {
77
- validateCallback('state update', updater)
92
+ validateCallback(`${TYPE_STATE} update`, updater)
78
93
 
79
94
  this.set(updater(this.#value))
80
95
  }
96
+
97
+ /**
98
+ * Register a callback to be called when HOOK_WATCH is triggered.
99
+ *
100
+ * @param {WatchHook} type - The type of hook to register the callback for; only HOOK_WATCH is supported
101
+ * @param {HookCallback} callback - The callback to register
102
+ * @returns {Cleanup} - A function to unregister the callback
103
+ */
104
+ on(type: WatchHook, callback: HookCallback): Cleanup {
105
+ if (type === HOOK_WATCH) {
106
+ this.#watchHookCallbacks ||= new Set()
107
+ this.#watchHookCallbacks.add(callback)
108
+ return () => {
109
+ this.#watchHookCallbacks?.delete(callback)
110
+ }
111
+ }
112
+ throw new InvalidHookError(this.constructor.name, type)
113
+ }
81
114
  }
82
115
 
83
116
  /* === Functions === */
@@ -1,15 +1,25 @@
1
1
  import { diff, type UnknownRecord } from '../diff'
2
- import { DuplicateKeyError, validateSignalValue } from '../errors'
2
+ import {
3
+ DuplicateKeyError,
4
+ InvalidHookError,
5
+ validateSignalValue,
6
+ } from '../errors'
3
7
  import { createMutableSignal, type MutableSignal, type Signal } from '../signal'
4
8
  import {
5
9
  type Cleanup,
6
- type Listener,
7
- type Listeners,
10
+ HOOK_ADD,
11
+ HOOK_CHANGE,
12
+ HOOK_REMOVE,
13
+ HOOK_WATCH,
14
+ type Hook,
15
+ type HookCallback,
16
+ isHandledHook,
8
17
  notifyWatchers,
9
18
  subscribeActiveWatcher,
19
+ UNSET,
10
20
  type Watcher,
11
21
  } from '../system'
12
- import { isFunction, isObjectOfType, isRecord, isSymbol, UNSET } from '../util'
22
+ import { isFunction, isObjectOfType, isRecord, isSymbol } from '../util'
13
23
  import { Composite } from './composite'
14
24
  import type { List } from './list'
15
25
  import type { State } from './state'
@@ -33,6 +43,7 @@ const TYPE_STORE = 'Store' as const
33
43
  class BaseStore<T extends UnknownRecord> {
34
44
  #composite: Composite<T, Signal<T[keyof T] & {}>>
35
45
  #watchers = new Set<Watcher>()
46
+ #watchHookCallbacks: Set<HookCallback> | undefined
36
47
 
37
48
  /**
38
49
  * Create a new store with the given initial value.
@@ -42,7 +53,7 @@ class BaseStore<T extends UnknownRecord> {
42
53
  * @throws {InvalidSignalValueError} - If the initial value is not an object
43
54
  */
44
55
  constructor(initialValue: T) {
45
- validateSignalValue('store', initialValue, isRecord)
56
+ validateSignalValue(TYPE_STORE, initialValue, isRecord)
46
57
 
47
58
  this.#composite = new Composite<T, Signal<T[keyof T] & {}>>(
48
59
  initialValue,
@@ -50,7 +61,7 @@ class BaseStore<T extends UnknownRecord> {
50
61
  key: K,
51
62
  value: unknown,
52
63
  ): value is T[K] & {} => {
53
- validateSignalValue(`store for key "${key}"`, value)
64
+ validateSignalValue(`${TYPE_STORE} for key "${key}"`, value)
54
65
  return true
55
66
  },
56
67
  value => createMutableSignal(value),
@@ -80,24 +91,6 @@ class BaseStore<T extends UnknownRecord> {
80
91
  yield [key, signal as MutableSignal<T[keyof T] & {}>]
81
92
  }
82
93
 
83
- get(): T {
84
- subscribeActiveWatcher(this.#watchers)
85
- return this.#value
86
- }
87
-
88
- set(newValue: T): void {
89
- if (UNSET === newValue) {
90
- this.#composite.clear()
91
- notifyWatchers(this.#watchers)
92
- this.#watchers.clear()
93
- return
94
- }
95
-
96
- const oldValue = this.#value
97
- const changed = this.#composite.change(diff(oldValue, newValue))
98
- if (changed) notifyWatchers(this.#watchers)
99
- }
100
-
101
94
  keys(): IterableIterator<string> {
102
95
  return this.#composite.signals.keys()
103
96
  }
@@ -122,13 +115,31 @@ class BaseStore<T extends UnknownRecord> {
122
115
  : State<T[K] & {}> | undefined
123
116
  }
124
117
 
118
+ get(): T {
119
+ subscribeActiveWatcher(this.#watchers, this.#watchHookCallbacks)
120
+ return this.#value
121
+ }
122
+
123
+ set(newValue: T): void {
124
+ if (UNSET === newValue) {
125
+ this.#composite.clear()
126
+ notifyWatchers(this.#watchers)
127
+ this.#watchers.clear()
128
+ return
129
+ }
130
+
131
+ const oldValue = this.#value
132
+ const changed = this.#composite.change(diff(oldValue, newValue))
133
+ if (changed) notifyWatchers(this.#watchers)
134
+ }
135
+
125
136
  update(fn: (oldValue: T) => T): void {
126
137
  this.set(fn(this.get()))
127
138
  }
128
139
 
129
140
  add<K extends keyof T & string>(key: K, value: T[K]): K {
130
141
  if (this.#composite.signals.has(key))
131
- throw new DuplicateKeyError('store', key, value)
142
+ throw new DuplicateKeyError(TYPE_STORE, key, value)
132
143
 
133
144
  const ok = this.#composite.add(key, value)
134
145
  if (ok) notifyWatchers(this.#watchers)
@@ -140,11 +151,17 @@ class BaseStore<T extends UnknownRecord> {
140
151
  if (ok) notifyWatchers(this.#watchers)
141
152
  }
142
153
 
143
- on<K extends keyof Omit<Listeners, 'sort'>>(
144
- type: K,
145
- listener: Listener<K>,
146
- ): Cleanup {
147
- return this.#composite.on(type, listener)
154
+ on(type: Hook, callback: HookCallback): Cleanup {
155
+ if (type === HOOK_WATCH) {
156
+ this.#watchHookCallbacks ||= new Set<HookCallback>()
157
+ this.#watchHookCallbacks.add(callback)
158
+ return () => {
159
+ this.#watchHookCallbacks?.delete(callback)
160
+ }
161
+ } else if (isHandledHook(type, [HOOK_ADD, HOOK_CHANGE, HOOK_REMOVE])) {
162
+ return this.#composite.on(type, callback)
163
+ }
164
+ throw new InvalidHookError(TYPE_STORE, type)
148
165
  }
149
166
  }
150
167