@zeix/cause-effect 0.17.1 → 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 (48) hide show
  1. package/.ai-context.md +7 -0
  2. package/.github/copilot-instructions.md +4 -0
  3. package/CLAUDE.md +96 -1
  4. package/README.md +44 -7
  5. package/archive/collection.ts +23 -25
  6. package/archive/computed.ts +3 -2
  7. package/archive/list.ts +21 -28
  8. package/archive/memo.ts +2 -1
  9. package/archive/state.ts +2 -1
  10. package/archive/store.ts +21 -32
  11. package/archive/task.ts +4 -7
  12. package/index.dev.js +356 -198
  13. package/index.js +1 -1
  14. package/index.ts +15 -6
  15. package/package.json +1 -1
  16. package/src/classes/collection.ts +69 -53
  17. package/src/classes/composite.ts +28 -33
  18. package/src/classes/computed.ts +87 -28
  19. package/src/classes/list.ts +31 -26
  20. package/src/classes/ref.ts +33 -5
  21. package/src/classes/state.ts +41 -8
  22. package/src/classes/store.ts +47 -30
  23. package/src/diff.ts +2 -1
  24. package/src/effect.ts +19 -9
  25. package/src/errors.ts +10 -1
  26. package/src/resolve.ts +1 -1
  27. package/src/signal.ts +0 -1
  28. package/src/system.ts +159 -43
  29. package/src/util.ts +0 -6
  30. package/test/collection.test.ts +279 -20
  31. package/test/computed.test.ts +268 -11
  32. package/test/effect.test.ts +2 -2
  33. package/test/list.test.ts +249 -21
  34. package/test/ref.test.ts +154 -0
  35. package/test/state.test.ts +13 -13
  36. package/test/store.test.ts +473 -28
  37. package/types/index.d.ts +3 -3
  38. package/types/src/classes/collection.d.ts +8 -7
  39. package/types/src/classes/composite.d.ts +4 -4
  40. package/types/src/classes/computed.d.ts +17 -0
  41. package/types/src/classes/list.d.ts +2 -2
  42. package/types/src/classes/ref.d.ts +10 -1
  43. package/types/src/classes/state.d.ts +9 -0
  44. package/types/src/classes/store.d.ts +4 -4
  45. package/types/src/effect.d.ts +1 -2
  46. package/types/src/errors.d.ts +4 -1
  47. package/types/src/system.d.ts +40 -24
  48. package/types/src/util.d.ts +1 -2
package/src/errors.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { isMutableSignal, type MutableSignal } from './signal'
2
- import { isFunction, isSymbol, UNSET, valueString } from './util'
2
+ import { UNSET } from './system'
3
+ import { isFunction, isSymbol, valueString } from './util'
3
4
 
4
5
  /* === Types === */
5
6
 
@@ -39,6 +40,13 @@ class InvalidCollectionSourceError extends TypeError {
39
40
  }
40
41
  }
41
42
 
43
+ class InvalidHookError extends TypeError {
44
+ constructor(where: string, type: string) {
45
+ super(`Invalid hook "${type}" in ${where}`)
46
+ this.name = 'InvalidHookError'
47
+ }
48
+ }
49
+
42
50
  class InvalidSignalValueError extends TypeError {
43
51
  constructor(where: string, value: unknown) {
44
52
  super(`Invalid signal value ${valueString(value)} in ${where}`)
@@ -100,6 +108,7 @@ export {
100
108
  DuplicateKeyError,
101
109
  InvalidCallbackError,
102
110
  InvalidCollectionSourceError,
111
+ InvalidHookError,
103
112
  InvalidSignalValueError,
104
113
  NullishSignalValueError,
105
114
  ReadonlySignalError,
package/src/resolve.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { UnknownRecord } from './diff'
2
2
  import { createError } from './errors'
3
3
  import type { SignalValues, UnknownSignalRecord } from './signal'
4
- import { UNSET } from './util'
4
+ import { UNSET } from './system'
5
5
 
6
6
  /* === Types === */
7
7
 
package/src/signal.ts CHANGED
@@ -10,7 +10,6 @@ import { isList, List } from './classes/list'
10
10
  import { isState, State } from './classes/state'
11
11
  import { createStore, isStore, type Store } from './classes/store'
12
12
  import type { UnknownRecord } from './diff'
13
- // import type { Collection } from './signals/collection'
14
13
  import { isRecord, isUniformArray } from './util'
15
14
 
16
15
  /* === Types === */
package/src/system.ts CHANGED
@@ -1,26 +1,27 @@
1
1
  /* === Types === */
2
2
 
3
+ import { createError, InvalidHookError } from './errors'
4
+ import { isFunction } from './util'
5
+
3
6
  type Cleanup = () => void
4
7
 
5
- type Watcher = {
6
- (): void
7
- onCleanup(cleanup: Cleanup): void
8
- stop(): void
9
- }
8
+ // biome-ignore lint/suspicious/noConfusingVoidType: optional Cleanup return type
9
+ type MaybeCleanup = Cleanup | undefined | void
10
10
 
11
- type Notifications = {
12
- add: readonly string[]
13
- change: readonly string[]
14
- remove: readonly string[]
15
- sort: readonly string[]
16
- }
11
+ type Hook = 'add' | 'change' | 'cleanup' | 'remove' | 'sort' | 'watch'
12
+ type CleanupHook = 'cleanup'
13
+ type WatchHook = 'watch'
17
14
 
18
- type Listener<K extends keyof Notifications> = (
19
- payload: Notifications[K],
20
- ) => void
15
+ type HookCallback = (payload?: readonly string[]) => MaybeCleanup
21
16
 
22
- type Listeners = {
23
- [K in keyof Notifications]: Set<Listener<K>>
17
+ type HookCallbacks = {
18
+ [K in Hook]?: Set<HookCallback>
19
+ }
20
+
21
+ type Watcher = {
22
+ (): void
23
+ on(type: CleanupHook, cleanup: Cleanup): void
24
+ stop(): void
24
25
  }
25
26
 
26
27
  /* === Internal === */
@@ -28,14 +29,29 @@ type Listeners = {
28
29
  // Currently active watcher
29
30
  let activeWatcher: Watcher | undefined
30
31
 
32
+ // Map of signal watchers to their cleanup functions
33
+ const unwatchMap = new WeakMap<Set<Watcher>, Set<Cleanup>>()
34
+
31
35
  // Queue of pending watcher reactions for batched change notifications
32
36
  const pendingReactions = new Set<() => void>()
33
37
  let batchDepth = 0
34
38
 
39
+ /* === Constants === */
40
+
41
+ // biome-ignore lint/suspicious/noExplicitAny: Deliberately using any to be used as a placeholder value in any signal
42
+ const UNSET: any = Symbol()
43
+
44
+ const HOOK_ADD = 'add'
45
+ const HOOK_CHANGE = 'change'
46
+ const HOOK_CLEANUP = 'cleanup'
47
+ const HOOK_REMOVE = 'remove'
48
+ const HOOK_SORT = 'sort'
49
+ const HOOK_WATCH = 'watch'
50
+
35
51
  /* === Functions === */
36
52
 
37
53
  /**
38
- * Create a watcher that can be used to observe changes to a signal
54
+ * Create a watcher to observe changes to a signal.
39
55
  *
40
56
  * A watcher is a reaction function with onCleanup and stop methods
41
57
  *
@@ -46,43 +62,86 @@ let batchDepth = 0
46
62
  const createWatcher = (react: () => void): Watcher => {
47
63
  const cleanups = new Set<Cleanup>()
48
64
  const watcher = react as Partial<Watcher>
49
- watcher.onCleanup = (cleanup: Cleanup) => {
50
- cleanups.add(cleanup)
65
+ watcher.on = (type: CleanupHook, cleanup: Cleanup) => {
66
+ if (type === HOOK_CLEANUP) cleanups.add(cleanup)
67
+ else throw new InvalidHookError('watcher', type)
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
+ * Subscribe by adding active watcher to the Set of watchers of a signal.
61
81
  *
62
82
  * @param {Set<Watcher>} watchers - Watchers of the signal
83
+ * @param {Set<HookCallback>} watchHookCallbacks - HOOK_WATCH callbacks of the signal
63
84
  */
64
- const subscribeActiveWatcher = (watchers: Set<Watcher>) => {
85
+ const subscribeActiveWatcher = (
86
+ watchers: Set<Watcher>,
87
+ watchHookCallbacks?: Set<HookCallback>,
88
+ ): void => {
89
+ // Check if we need to trigger HOOK_WATCH callbacks
90
+ if (!watchers.size && watchHookCallbacks?.size) {
91
+ const unwatch = triggerHook(watchHookCallbacks)
92
+ if (unwatch) {
93
+ const unwatchCallbacks =
94
+ unwatchMap.get(watchers) ?? new Set<Cleanup>()
95
+ unwatchCallbacks.add(unwatch)
96
+ if (!unwatchMap.has(watchers))
97
+ unwatchMap.set(watchers, unwatchCallbacks)
98
+ }
99
+ }
100
+
101
+ // Only if active watcher is not already subscribed
65
102
  if (activeWatcher && !watchers.has(activeWatcher)) {
66
103
  const watcher = activeWatcher
67
- watcher.onCleanup(() => watchers.delete(watcher))
104
+
105
+ watcher.on(HOOK_CLEANUP, () => {
106
+ // Remove the watcher from the Set of watchers
107
+ watchers.delete(watcher)
108
+
109
+ // If it was the last watcher, call unwatch callbacks
110
+ if (!watchers.size) {
111
+ const unwatchCallbacks = unwatchMap.get(watchers)
112
+ if (unwatchCallbacks) {
113
+ try {
114
+ for (const unwatch of unwatchCallbacks) unwatch()
115
+ } finally {
116
+ unwatchCallbacks.clear()
117
+ unwatchMap.delete(watchers)
118
+ }
119
+ }
120
+ }
121
+ })
122
+
123
+ // Here the active watcher is added to the Set of watchers
68
124
  watchers.add(watcher)
69
125
  }
70
126
  }
71
127
 
72
128
  /**
73
- * Notify watchers of a signal change
129
+ * Notify watchers of a signal change.
74
130
  *
75
131
  * @param {Set<Watcher>} watchers - Watchers of the signal
132
+ * @returns {boolean} - Whether any watchers were notified
76
133
  */
77
- const notifyWatchers = (watchers: Set<Watcher>) => {
134
+ const notifyWatchers = (watchers: Set<Watcher>): boolean => {
135
+ if (!watchers.size) return false
78
136
  for (const react of watchers) {
79
137
  if (batchDepth) pendingReactions.add(react)
80
138
  else react()
81
139
  }
140
+ return true
82
141
  }
83
142
 
84
143
  /**
85
- * Flush all pending reactions of enqueued watchers
144
+ * Flush all pending reactions of enqueued watchers.
86
145
  */
87
146
  const flushPendingReactions = () => {
88
147
  while (pendingReactions.size) {
@@ -93,7 +152,7 @@ const flushPendingReactions = () => {
93
152
  }
94
153
 
95
154
  /**
96
- * Batch multiple signal writes
155
+ * Batch multiple signal writes.
97
156
  *
98
157
  * @param {() => void} callback - Function with multiple signal writes to be batched
99
158
  */
@@ -108,7 +167,7 @@ const batchSignalWrites = (callback: () => void) => {
108
167
  }
109
168
 
110
169
  /**
111
- * Run a function with signal reads in a tracking context (or temporarily untrack)
170
+ * Run a function with signal reads in a tracking context (or temporarily untrack).
112
171
  *
113
172
  * @param {Watcher | false} watcher - Watcher to be called when the signal changes
114
173
  * or false for temporary untracking while inserting auto-hydrating DOM nodes
@@ -126,34 +185,91 @@ const trackSignalReads = (watcher: Watcher | false, run: () => void): void => {
126
185
  }
127
186
 
128
187
  /**
129
- * Emit a notification to listeners
188
+ * Trigger a hook.
130
189
  *
131
- * @param {Set<Listener>} listeners - Listeners to be notified
132
- * @param {Notifications[K]} payload - Payload to be sent to listeners
190
+ * @param {Set<HookCallback> | undefined} callbacks - Callbacks to be called when the hook is triggered
191
+ * @param {readonly string[] | undefined} payload - Payload to be sent to listeners
192
+ * @return {Cleanup | undefined} Cleanup function to be called when the hook is unmounted
133
193
  */
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)
194
+ const triggerHook = (
195
+ callbacks: Set<HookCallback> | undefined,
196
+ payload?: readonly string[],
197
+ ): Cleanup | undefined => {
198
+ if (!callbacks) return
199
+
200
+ const cleanups: Cleanup[] = []
201
+ const errors: Error[] = []
202
+
203
+ const throwError = (inCleanup?: boolean) => {
204
+ if (errors.length) {
205
+ if (errors.length === 1) throw errors[0]
206
+ throw new AggregateError(
207
+ errors,
208
+ `Errors in hook ${inCleanup ? 'cleanup' : 'callback'}:`,
209
+ )
210
+ }
211
+ }
212
+
213
+ for (const callback of callbacks) {
214
+ try {
215
+ const cleanup = callback(payload)
216
+ if (isFunction(cleanup)) cleanups.push(cleanup)
217
+ } catch (error) {
218
+ errors.push(createError(error))
219
+ }
220
+ }
221
+ throwError()
222
+
223
+ if (!cleanups.length) return
224
+ if (cleanups.length === 1) return cleanups[0]
225
+ return () => {
226
+ for (const cleanup of cleanups) {
227
+ try {
228
+ cleanup()
229
+ } catch (error) {
230
+ errors.push(createError(error))
231
+ }
232
+ }
233
+ throwError(true)
141
234
  }
142
235
  }
143
236
 
237
+ /**
238
+ * Check whether a hook type is handled in a signal.
239
+ *
240
+ * @param {Hook} type - Type of hook to check
241
+ * @param {T} handled - List of handled hook types
242
+ * @returns {type is T[number]} - Whether the hook type is handled
243
+ */
244
+ const isHandledHook = <T extends readonly Hook[]>(
245
+ type: Hook,
246
+ handled: T,
247
+ ): type is T[number] => handled.includes(type)
248
+
144
249
  /* === Exports === */
145
250
 
146
251
  export {
147
252
  type Cleanup,
253
+ type MaybeCleanup,
148
254
  type Watcher,
149
- type Notifications,
150
- type Listener,
151
- type Listeners,
255
+ type Hook,
256
+ type CleanupHook,
257
+ type WatchHook,
258
+ type HookCallback,
259
+ type HookCallbacks,
260
+ HOOK_ADD,
261
+ HOOK_CHANGE,
262
+ HOOK_CLEANUP,
263
+ HOOK_REMOVE,
264
+ HOOK_SORT,
265
+ HOOK_WATCH,
266
+ UNSET,
152
267
  createWatcher,
153
268
  subscribeActiveWatcher,
154
269
  notifyWatchers,
155
270
  flushPendingReactions,
156
271
  batchSignalWrites,
157
272
  trackSignalReads,
158
- emitNotification,
273
+ triggerHook,
274
+ isHandledHook,
159
275
  }
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,