@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
package/src/system.ts CHANGED
@@ -1,36 +1,34 @@
1
- /* === Types === */
1
+ import { assert, type Guard } from './errors'
2
+ import type { UnknownSignal } from './signal'
2
3
 
3
- import { createError, InvalidHookError } from './errors'
4
- import { isFunction } from './util'
4
+ /* === Types === */
5
5
 
6
6
  type Cleanup = () => void
7
7
 
8
8
  // biome-ignore lint/suspicious/noConfusingVoidType: optional Cleanup return type
9
9
  type MaybeCleanup = Cleanup | undefined | void
10
10
 
11
- type Hook = 'add' | 'change' | 'cleanup' | 'remove' | 'sort' | 'watch'
12
- type CleanupHook = 'cleanup'
13
- type WatchHook = 'watch'
14
-
15
- type HookCallback = (payload?: readonly string[]) => MaybeCleanup
16
-
17
- type HookCallbacks = {
18
- [K in Hook]?: Set<HookCallback>
19
- }
20
-
21
11
  type Watcher = {
22
12
  (): void
23
- on(type: CleanupHook, cleanup: Cleanup): void
13
+ run(): void
14
+ onCleanup(cleanup: Cleanup): void
24
15
  stop(): void
25
16
  }
26
17
 
18
+ type SignalOptions<T extends unknown & {}> = {
19
+ guard?: Guard<T>
20
+ watched?: () => void
21
+ unwatched?: () => void
22
+ }
23
+
27
24
  /* === Internal === */
28
25
 
29
26
  // Currently active watcher
30
27
  let activeWatcher: Watcher | undefined
31
28
 
32
- // Map of signal watchers to their cleanup functions
33
- const unwatchMap = new WeakMap<Set<Watcher>, Set<Cleanup>>()
29
+ const watchersMap = new WeakMap<UnknownSignal, Set<Watcher>>()
30
+ const watchedCallbackMap = new WeakMap<object, () => void>()
31
+ const unwatchedCallbackMap = new WeakMap<object, () => void>()
34
32
 
35
33
  // Queue of pending watcher reactions for batched change notifications
36
34
  const pendingReactions = new Set<() => void>()
@@ -41,30 +39,32 @@ let batchDepth = 0
41
39
  // biome-ignore lint/suspicious/noExplicitAny: Deliberately using any to be used as a placeholder value in any signal
42
40
  const UNSET: any = Symbol()
43
41
 
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
-
51
42
  /* === Functions === */
52
43
 
53
44
  /**
54
- * Create a watcher to observe changes to a signal.
45
+ * Create a watcher to observe changes in signals.
55
46
  *
56
- * A watcher is a reaction function with onCleanup and stop methods
47
+ * A watcher combines push and pull reaction functions with onCleanup and stop methods
57
48
  *
58
- * @since 0.14.1
59
- * @param {() => void} react - Function to be called when the state changes
49
+ * @since 0.17.3
50
+ * @param {() => void} push - Function to be called when the state changes (push)
51
+ * @param {() => void} pull - Function to be called on demand from consumers (pull)
60
52
  * @returns {Watcher} - Watcher object with off and cleanup methods
61
53
  */
62
- const createWatcher = (react: () => void): Watcher => {
54
+ const createWatcher = (push: () => void, pull: () => void): Watcher => {
63
55
  const cleanups = new Set<Cleanup>()
64
- const watcher = react as Partial<Watcher>
65
- watcher.on = (type: CleanupHook, cleanup: Cleanup) => {
66
- if (type === HOOK_CLEANUP) cleanups.add(cleanup)
67
- else throw new InvalidHookError('watcher', type)
56
+ const watcher = push as Partial<Watcher>
57
+ watcher.run = () => {
58
+ const prev = activeWatcher
59
+ activeWatcher = watcher as Watcher
60
+ try {
61
+ pull()
62
+ } finally {
63
+ activeWatcher = prev
64
+ }
65
+ }
66
+ watcher.onCleanup = (cleanup: Cleanup) => {
67
+ cleanups.add(cleanup)
68
68
  }
69
69
  watcher.stop = () => {
70
70
  try {
@@ -77,62 +77,113 @@ const createWatcher = (react: () => void): Watcher => {
77
77
  }
78
78
 
79
79
  /**
80
- * Subscribe by adding active watcher to the Set of watchers of a signal.
80
+ * Run a function with signal reads in a non-tracking context.
81
81
  *
82
- * @param {Set<Watcher>} watchers - Watchers of the signal
83
- * @param {Set<HookCallback>} watchHookCallbacks - HOOK_WATCH callbacks of the signal
82
+ * @param {() => void} callback - Callback
84
83
  */
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
- }
84
+ const untrack = (callback: () => void): void => {
85
+ const prev = activeWatcher
86
+ activeWatcher = undefined
87
+ try {
88
+ callback()
89
+ } finally {
90
+ activeWatcher = prev
99
91
  }
92
+ }
100
93
 
101
- // Only if active watcher is not already subscribed
102
- if (activeWatcher && !watchers.has(activeWatcher)) {
103
- const watcher = activeWatcher
94
+ const registerWatchCallbacks = (
95
+ signal: UnknownSignal,
96
+ watched: () => void,
97
+ unwatched?: () => void,
98
+ ) => {
99
+ watchedCallbackMap.set(signal, watched)
100
+ if (unwatched) unwatchedCallbackMap.set(signal, unwatched)
101
+ }
104
102
 
105
- watcher.on(HOOK_CLEANUP, () => {
106
- // Remove the watcher from the Set of watchers
107
- watchers.delete(watcher)
103
+ /**
104
+ * Subscribe active watcher to a signal.
105
+ *
106
+ * @param {UnknownSignal} signal - Signal to subscribe to
107
+ * @returns {boolean} - true if the active watcher was subscribed,
108
+ * false if the watcher was already subscribed or there was no active watcher
109
+ */
110
+ const subscribeTo = (signal: UnknownSignal): boolean => {
111
+ if (!activeWatcher || watchersMap.get(signal)?.has(activeWatcher))
112
+ return false
113
+
114
+ const watcher = activeWatcher
115
+ if (!watchersMap.has(signal)) watchersMap.set(signal, new Set<Watcher>())
116
+
117
+ const watchers = watchersMap.get(signal)
118
+ assert(watchers)
119
+ if (!watchers.size) {
120
+ const watchedCallback = watchedCallbackMap.get(signal)
121
+ if (watchedCallback) untrack(watchedCallback)
122
+ }
123
+ watchers.add(watcher)
124
+ watcher.onCleanup(() => {
125
+ watchers.delete(watcher)
126
+ if (!watchers.size) {
127
+ const unwatchedCallback = unwatchedCallbackMap.get(signal)
128
+ if (unwatchedCallback) untrack(unwatchedCallback)
129
+ }
130
+ })
131
+ return true
132
+ }
108
133
 
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
- })
134
+ const subscribeActiveWatcher = (watchers: Set<Watcher>) => {
135
+ if (!activeWatcher || watchers.has(activeWatcher)) return false
122
136
 
123
- // Here the active watcher is added to the Set of watchers
124
- watchers.add(watcher)
137
+ const watcher = activeWatcher
138
+ watchers.add(watcher)
139
+ if (!watchers.size) {
140
+ const watchedCallback = watchedCallbackMap.get(watchers)
141
+ if (watchedCallback) untrack(watchedCallback)
125
142
  }
143
+ watcher.onCleanup(() => {
144
+ watchers.delete(watcher)
145
+ if (!watchers.size) {
146
+ const unwatchedCallback = unwatchedCallbackMap.get(watchers)
147
+ if (unwatchedCallback) untrack(unwatchedCallback)
148
+ }
149
+ })
150
+ return true
151
+ }
152
+
153
+ /**
154
+ * Unsubscribe all watchers from a signal so it can be garbage collected.
155
+ *
156
+ * @param {UnknownSignal} signal - Signal to unsubscribe from
157
+ * @returns {void}
158
+ */
159
+ const unsubscribeAllFrom = (signal: UnknownSignal): void => {
160
+ const watchers = watchersMap.get(signal)
161
+ if (!watchers) return
162
+
163
+ for (const watcher of watchers) watcher.stop()
164
+ watchers.clear()
126
165
  }
127
166
 
128
167
  /**
129
168
  * Notify watchers of a signal change.
130
169
  *
131
- * @param {Set<Watcher>} watchers - Watchers of the signal
170
+ * @param {UnknownSignal} signal - Signal to notify watchers of
132
171
  * @returns {boolean} - Whether any watchers were notified
133
172
  */
173
+ const notifyOf = (signal: UnknownSignal): boolean => {
174
+ const watchers = watchersMap.get(signal)
175
+ if (!watchers?.size) return false
176
+
177
+ for (const react of watchers) {
178
+ if (batchDepth) pendingReactions.add(react)
179
+ else react()
180
+ }
181
+ return true
182
+ }
183
+
134
184
  const notifyWatchers = (watchers: Set<Watcher>): boolean => {
135
185
  if (!watchers.size) return false
186
+
136
187
  for (const react of watchers) {
137
188
  if (batchDepth) pendingReactions.add(react)
138
189
  else react()
@@ -143,11 +194,11 @@ const notifyWatchers = (watchers: Set<Watcher>): boolean => {
143
194
  /**
144
195
  * Flush all pending reactions of enqueued watchers.
145
196
  */
146
- const flushPendingReactions = () => {
197
+ const flush = () => {
147
198
  while (pendingReactions.size) {
148
199
  const watchers = Array.from(pendingReactions)
149
200
  pendingReactions.clear()
150
- for (const watcher of watchers) watcher()
201
+ for (const react of watchers) react()
151
202
  }
152
203
  }
153
204
 
@@ -156,12 +207,12 @@ const flushPendingReactions = () => {
156
207
  *
157
208
  * @param {() => void} callback - Function with multiple signal writes to be batched
158
209
  */
159
- const batchSignalWrites = (callback: () => void) => {
210
+ const batch = (callback: () => void) => {
160
211
  batchDepth++
161
212
  try {
162
213
  callback()
163
214
  } finally {
164
- flushPendingReactions()
215
+ flush()
165
216
  batchDepth--
166
217
  }
167
218
  }
@@ -174,7 +225,7 @@ const batchSignalWrites = (callback: () => void) => {
174
225
  * that might read signals (e.g., Web Components)
175
226
  * @param {() => void} run - Function to run the computation or effect
176
227
  */
177
- const trackSignalReads = (watcher: Watcher | false, run: () => void): void => {
228
+ const track = (watcher: Watcher | false, run: () => void): void => {
178
229
  const prev = activeWatcher
179
230
  activeWatcher = watcher || undefined
180
231
  try {
@@ -184,92 +235,23 @@ const trackSignalReads = (watcher: Watcher | false, run: () => void): void => {
184
235
  }
185
236
  }
186
237
 
187
- /**
188
- * Trigger a hook.
189
- *
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
193
- */
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)
234
- }
235
- }
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
-
249
238
  /* === Exports === */
250
239
 
251
240
  export {
252
241
  type Cleanup,
253
242
  type MaybeCleanup,
254
243
  type Watcher,
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,
244
+ type SignalOptions,
266
245
  UNSET,
267
246
  createWatcher,
247
+ registerWatchCallbacks,
248
+ subscribeTo,
268
249
  subscribeActiveWatcher,
250
+ unsubscribeAllFrom,
251
+ notifyOf,
269
252
  notifyWatchers,
270
- flushPendingReactions,
271
- batchSignalWrites,
272
- trackSignalReads,
273
- triggerHook,
274
- isHandledHook,
253
+ flush,
254
+ batch,
255
+ track,
256
+ untrack,
275
257
  }
@@ -1,12 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import {
3
- batchSignalWrites,
4
- createEffect,
5
- Memo,
6
- match,
7
- resolve,
8
- State,
9
- } from '../index.ts'
2
+ import { batch, createEffect, Memo, match, resolve, State } from '../index.ts'
10
3
 
11
4
  /* === Tests === */
12
5
 
@@ -19,7 +12,7 @@ describe('Batch', () => {
19
12
  result = cause.get()
20
13
  count++
21
14
  })
22
- batchSignalWrites(() => {
15
+ batch(() => {
23
16
  for (let i = 1; i <= 10; i++) cause.set(i)
24
17
  })
25
18
  expect(result).toBe(10)
@@ -43,7 +36,7 @@ describe('Batch', () => {
43
36
  err: () => {},
44
37
  })
45
38
  })
46
- batchSignalWrites(() => {
39
+ batch(() => {
47
40
  a.set(6)
48
41
  b.set(8)
49
42
  c.set(10)
@@ -87,7 +80,7 @@ describe('Batch', () => {
87
80
  expect(result).toBe(10)
88
81
 
89
82
  // Batch: apply changes to all signals in a single transaction
90
- batchSignalWrites(() => {
83
+ batch(() => {
91
84
  signals.forEach(signal => signal.update(v => v * 2))
92
85
  })
93
86
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, mock, test } from 'bun:test'
2
- import { batchSignalWrites, createEffect, Memo, State } from '../index.ts'
2
+ import { batch, createEffect, Memo, State } from '../index.ts'
3
3
  import { Counter, makeGraph, runGraph } from './util/dependency-graph'
4
4
  import type { Computed, ReactiveFramework } from './util/reactive-framework'
5
5
 
@@ -28,7 +28,7 @@ const framework = {
28
28
  }
29
29
  },
30
30
  effect: (fn: () => undefined) => createEffect(fn),
31
- withBatch: (fn: () => undefined) => batchSignalWrites(fn),
31
+ withBatch: (fn: () => undefined) => batch(fn),
32
32
  withBuild: <T>(fn: () => T) => fn(),
33
33
  }
34
34
  const testPullCounts = true
@@ -449,6 +449,7 @@ describe('$mol_wire tests', () => {
449
449
  const name = framework.name
450
450
 
451
451
  test(`${name} | $mol_wire benchmark`, () => {
452
+ // @ts-expect-error test
452
453
  const fib = (n: number) => {
453
454
  if (n < 2) return 1
454
455
  return fib(n - 1) + fib(n - 2)
@@ -618,6 +619,7 @@ describe('CellX tests', () => {
618
619
  for (const layers in expected) {
619
620
  // @ts-expect-error - Framework object has incompatible type constraints with ReactiveFramework
620
621
  const [before, after] = cellx(framework, layers)
622
+ // @ts-expect-error - Framework object has incompatible type constraints with ReactiveFramework
621
623
  const [expectedBefore, expectedAfter] = expected[layers]
622
624
  expect(before.toString()).toBe(expectedBefore.toString())
623
625
  expect(after.toString()).toBe(expectedAfter.toString())