@zeix/cause-effect 0.16.1 → 0.17.0

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 (61) hide show
  1. package/.ai-context.md +71 -21
  2. package/.cursorrules +3 -2
  3. package/.github/copilot-instructions.md +59 -13
  4. package/CLAUDE.md +170 -24
  5. package/LICENSE +1 -1
  6. package/README.md +156 -52
  7. package/archive/benchmark.ts +688 -0
  8. package/archive/collection.ts +312 -0
  9. package/{src → archive}/computed.ts +19 -19
  10. package/archive/list.ts +551 -0
  11. package/archive/memo.ts +138 -0
  12. package/{src → archive}/state.ts +13 -11
  13. package/archive/store.ts +368 -0
  14. package/archive/task.ts +194 -0
  15. package/eslint.config.js +1 -0
  16. package/index.dev.js +899 -503
  17. package/index.js +1 -1
  18. package/index.ts +41 -22
  19. package/package.json +1 -1
  20. package/src/classes/collection.ts +272 -0
  21. package/src/classes/composite.ts +176 -0
  22. package/src/classes/computed.ts +333 -0
  23. package/src/classes/list.ts +304 -0
  24. package/src/classes/state.ts +98 -0
  25. package/src/classes/store.ts +210 -0
  26. package/src/diff.ts +26 -53
  27. package/src/effect.ts +9 -9
  28. package/src/errors.ts +50 -25
  29. package/src/signal.ts +58 -41
  30. package/src/system.ts +79 -42
  31. package/src/util.ts +16 -30
  32. package/test/batch.test.ts +15 -17
  33. package/test/benchmark.test.ts +4 -4
  34. package/test/collection.test.ts +796 -0
  35. package/test/computed.test.ts +138 -130
  36. package/test/diff.test.ts +2 -2
  37. package/test/effect.test.ts +36 -35
  38. package/test/list.test.ts +754 -0
  39. package/test/match.test.ts +25 -25
  40. package/test/resolve.test.ts +17 -19
  41. package/test/signal.test.ts +70 -119
  42. package/test/state.test.ts +44 -44
  43. package/test/store.test.ts +253 -929
  44. package/types/index.d.ts +10 -8
  45. package/types/src/classes/collection.d.ts +32 -0
  46. package/types/src/classes/composite.d.ts +15 -0
  47. package/types/src/classes/computed.d.ts +97 -0
  48. package/types/src/classes/list.d.ts +41 -0
  49. package/types/src/classes/state.d.ts +52 -0
  50. package/types/src/classes/store.d.ts +51 -0
  51. package/types/src/diff.d.ts +8 -12
  52. package/types/src/errors.d.ts +12 -11
  53. package/types/src/signal.d.ts +27 -14
  54. package/types/src/system.d.ts +41 -20
  55. package/types/src/util.d.ts +6 -3
  56. package/src/store.ts +0 -474
  57. package/types/src/collection.d.ts +0 -26
  58. package/types/src/computed.d.ts +0 -33
  59. package/types/src/scheduler.d.ts +0 -55
  60. package/types/src/state.d.ts +0 -24
  61. package/types/src/store.d.ts +0 -65
package/src/signal.ts CHANGED
@@ -1,13 +1,17 @@
1
1
  import {
2
2
  type Computed,
3
- type ComputedCallback,
4
- createComputed,
5
3
  isComputed,
6
- isComputedCallback,
7
- } from './computed'
8
- import { createState, isState, type State } from './state'
9
- import { createStore, isStore, type Store } from './store'
10
- import { isRecord } from './util'
4
+ isMemoCallback,
5
+ isTaskCallback,
6
+ Memo,
7
+ Task,
8
+ } from './classes/computed'
9
+ import { isList, List } from './classes/list'
10
+ import { isState, State } from './classes/state'
11
+ import { createStore, isStore, type Store } from './classes/store'
12
+ import type { UnknownRecord } from './diff'
13
+ // import type { Collection } from './signals/collection'
14
+ import { isRecord, isUniformArray } from './util'
11
15
 
12
16
  /* === Types === */
13
17
 
@@ -15,6 +19,13 @@ type Signal<T extends {}> = {
15
19
  get(): T
16
20
  }
17
21
 
22
+ type MutableSignal<T extends {}> = T extends readonly (infer U extends {})[]
23
+ ? List<U>
24
+ : T extends UnknownRecord
25
+ ? Store<T>
26
+ : State<T>
27
+ type ReadonlySignal<T extends {}> = Computed<T> // | Collection<T>
28
+
18
29
  type UnknownSignalRecord = Record<string, Signal<unknown & {}>>
19
30
 
20
31
  type SignalValues<S extends UnknownSignalRecord> = {
@@ -35,54 +46,60 @@ const isSignal = /*#__PURE__*/ <T extends {}>(
35
46
  ): value is Signal<T> => isState(value) || isComputed(value) || isStore(value)
36
47
 
37
48
  /**
38
- * Check whether a value is a State or Store
49
+ * Check whether a value is a State, Store, or List
39
50
  *
40
51
  * @since 0.15.2
41
- * @param {unknown} value - value to check
42
- * @returns {boolean} - true if value is a State or Store, false otherwise
52
+ * @param {unknown} value - Value to check
53
+ * @returns {boolean} - True if value is a State, Store, or List, false otherwise
43
54
  */
44
- const isMutableSignal = /*#__PURE__*/ <T extends {}>(
55
+ const isMutableSignal = /*#__PURE__*/ (
45
56
  value: unknown,
46
- ): value is State<T> | Store<T> => isState(value) || isStore(value)
57
+ ): value is MutableSignal<unknown & {}> =>
58
+ isState(value) || isStore(value) || isList(value)
47
59
 
48
60
  /**
49
- * Convert a value to a Signal if it's not already a Signal
61
+ * Convert a value to a Signal.
50
62
  *
51
63
  * @since 0.9.6
52
- * @param {T} value - value to convert
53
- * @returns {Signal<T>} - Signal instance
54
64
  */
55
- function toSignal<T extends {}>(
56
- value: T,
57
- ): T extends Store<infer U>
58
- ? Store<U>
59
- : T extends State<infer U>
60
- ? State<U>
61
- : T extends Computed<infer U>
62
- ? Computed<U>
63
- : T extends Signal<infer U>
64
- ? Signal<U>
65
- : T extends ReadonlyArray<infer U extends {}>
66
- ? Store<U[]>
67
- : T extends Record<string, unknown & {}>
68
- ? Store<{ [K in keyof T]: T[K] }>
69
- : T extends ComputedCallback<infer U extends {}>
70
- ? Computed<U>
71
- : State<T>
72
- function toSignal<T extends {}>(value: T) {
73
- if (isSignal<T>(value)) return value
74
- if (isComputedCallback(value)) return createComputed(value)
75
- if (Array.isArray(value) || isRecord(value)) return createStore(value)
76
- return createState(value)
65
+ function createSignal<T extends {}>(value: readonly T[]): List<T>
66
+ function createSignal<T extends {}>(value: T[]): List<T>
67
+ function createSignal<T extends UnknownRecord>(value: T): Store<T>
68
+ function createSignal<T extends {}>(value: () => T): Computed<T>
69
+ function createSignal<T extends {}>(value: T): State<T>
70
+ function createSignal(value: unknown): unknown {
71
+ if (isMemoCallback(value)) return new Memo(value)
72
+ if (isTaskCallback(value)) return new Task(value)
73
+ if (isUniformArray<unknown & {}>(value)) return new List(value)
74
+ if (isRecord(value)) return createStore(value as UnknownRecord)
75
+ return new State(value as unknown & {})
76
+ }
77
+
78
+ /**
79
+ * Convert a value to a MutableSignal.
80
+ *
81
+ * @since 0.17.0
82
+ */
83
+ function createMutableSignal<T extends {}>(value: readonly T[]): List<T>
84
+ function createMutableSignal<T extends {}>(value: T[]): List<T>
85
+ function createMutableSignal<T extends UnknownRecord>(value: T): Store<T>
86
+ function createMutableSignal<T extends {}>(value: T): State<T>
87
+ function createMutableSignal(value: unknown): unknown {
88
+ if (isUniformArray<unknown & {}>(value)) return new List(value)
89
+ if (isRecord(value)) return createStore(value as UnknownRecord)
90
+ return new State(value as unknown & {})
77
91
  }
78
92
 
79
93
  /* === Exports === */
80
94
 
81
95
  export {
96
+ createMutableSignal,
97
+ createSignal,
98
+ isMutableSignal,
99
+ isSignal,
100
+ type MutableSignal,
101
+ type ReadonlySignal,
82
102
  type Signal,
83
- type UnknownSignalRecord,
84
103
  type SignalValues,
85
- isSignal,
86
- isMutableSignal,
87
- toSignal,
104
+ type UnknownSignalRecord,
88
105
  }
package/src/system.ts CHANGED
@@ -4,8 +4,23 @@ type Cleanup = () => void
4
4
 
5
5
  type Watcher = {
6
6
  (): void
7
- unwatch(cleanup: Cleanup): void
8
- cleanup(): void
7
+ onCleanup(cleanup: Cleanup): void
8
+ stop(): void
9
+ }
10
+
11
+ type Notifications = {
12
+ add: readonly string[]
13
+ change: readonly string[]
14
+ remove: readonly string[]
15
+ sort: readonly string[]
16
+ }
17
+
18
+ type Listener<K extends keyof Notifications> = (
19
+ payload: Notifications[K],
20
+ ) => void
21
+
22
+ type Listeners = {
23
+ [K in keyof Notifications]: Set<Listener<K>>
9
24
  }
10
25
 
11
26
  /* === Internal === */
@@ -13,8 +28,8 @@ type Watcher = {
13
28
  // Currently active watcher
14
29
  let activeWatcher: Watcher | undefined
15
30
 
16
- // Pending queue for batched change notifications
17
- const pendingWatchers = new Set<Watcher>()
31
+ // Queue of pending watcher reactions for batched change notifications
32
+ const pendingReactions = new Set<() => void>()
18
33
  let batchDepth = 0
19
34
 
20
35
  /* === Functions === */
@@ -22,85 +37,87 @@ let batchDepth = 0
22
37
  /**
23
38
  * Create a watcher that can be used to observe changes to a signal
24
39
  *
40
+ * A watcher is a reaction function with onCleanup and stop methods
41
+ *
25
42
  * @since 0.14.1
26
- * @param {() => void} watch - Function to be called when the state changes
43
+ * @param {() => void} react - Function to be called when the state changes
27
44
  * @returns {Watcher} - Watcher object with off and cleanup methods
28
45
  */
29
- const createWatcher = (watch: () => void): Watcher => {
46
+ const createWatcher = (react: () => void): Watcher => {
30
47
  const cleanups = new Set<Cleanup>()
31
- const w = watch as Partial<Watcher>
32
- w.unwatch = (cleanup: Cleanup) => {
48
+ const watcher = react as Partial<Watcher>
49
+ watcher.onCleanup = (cleanup: Cleanup) => {
33
50
  cleanups.add(cleanup)
34
51
  }
35
- w.cleanup = () => {
52
+ watcher.stop = () => {
36
53
  for (const cleanup of cleanups) cleanup()
37
54
  cleanups.clear()
38
55
  }
39
- return w as Watcher
56
+ return watcher as Watcher
40
57
  }
41
58
 
42
59
  /**
43
- * Add active watcher to the Set of watchers
60
+ * Subscribe by adding active watcher to the Set of watchers of a signal
44
61
  *
45
- * @param {Set<Watcher>} watchers - watchers of the signal
62
+ * @param {Set<Watcher>} watchers - Watchers of the signal
46
63
  */
47
- const subscribe = (watchers: Set<Watcher>) => {
64
+ const subscribeActiveWatcher = (watchers: Set<Watcher>) => {
48
65
  if (activeWatcher && !watchers.has(activeWatcher)) {
49
66
  const watcher = activeWatcher
50
- watcher.unwatch(() => {
51
- watchers.delete(watcher)
52
- })
67
+ watcher.onCleanup(() => watchers.delete(watcher))
53
68
  watchers.add(watcher)
54
69
  }
55
70
  }
56
71
 
57
72
  /**
58
- * Add watchers to the pending set of change notifications
73
+ * Notify watchers of a signal change
59
74
  *
60
- * @param {Set<Watcher>} watchers - watchers of the signal
75
+ * @param {Set<Watcher>} watchers - Watchers of the signal
61
76
  */
62
- const notify = (watchers: Set<Watcher>) => {
63
- for (const watcher of watchers) {
64
- if (batchDepth) pendingWatchers.add(watcher)
65
- else watcher()
77
+ const notifyWatchers = (watchers: Set<Watcher>) => {
78
+ for (const react of watchers) {
79
+ if (batchDepth) pendingReactions.add(react)
80
+ else react()
66
81
  }
67
82
  }
68
83
 
69
84
  /**
70
- * Flush all pending changes to notify watchers
85
+ * Flush all pending reactions of enqueued watchers
71
86
  */
72
- const flush = () => {
73
- while (pendingWatchers.size) {
74
- const watchers = Array.from(pendingWatchers)
75
- pendingWatchers.clear()
87
+ const flushPendingReactions = () => {
88
+ while (pendingReactions.size) {
89
+ const watchers = Array.from(pendingReactions)
90
+ pendingReactions.clear()
76
91
  for (const watcher of watchers) watcher()
77
92
  }
78
93
  }
79
94
 
80
95
  /**
81
- * Batch multiple changes in a single signal graph and DOM update cycle
96
+ * Batch multiple signal writes
82
97
  *
83
- * @param {() => void} fn - function with multiple signal writes to be batched
98
+ * @param {() => void} callback - Function with multiple signal writes to be batched
84
99
  */
85
- const batch = (fn: () => void) => {
100
+ const batchSignalWrites = (callback: () => void) => {
86
101
  batchDepth++
87
102
  try {
88
- fn()
103
+ callback()
89
104
  } finally {
90
- flush()
105
+ flushPendingReactions()
91
106
  batchDepth--
92
107
  }
93
108
  }
94
109
 
95
110
  /**
96
- * Run a function in a reactive context
111
+ * Run a function with signal reads in a tracking context (or temporarily untrack)
97
112
  *
98
- * @param {() => void} run - function to run the computation or effect
99
- * @param {Watcher} watcher - function to be called when the state changes or undefined for temporary unwatching while inserting auto-hydrating DOM nodes that might read signals (e.g., web components)
113
+ * @param {Watcher | false} watcher - Watcher to be called when the signal changes
114
+ * or false for temporary untracking while inserting auto-hydrating DOM nodes
115
+ * that might read signals (e.g., Web Components)
116
+ * @param {() => void} run - Function to run the computation or effect
100
117
  */
101
- const observe = (run: () => void, watcher?: Watcher): void => {
118
+ const trackSignalReads = (watcher: Watcher | false, run: () => void): void => {
102
119
  const prev = activeWatcher
103
- activeWatcher = watcher
120
+ activeWatcher = watcher || undefined
104
121
  try {
105
122
  run()
106
123
  } finally {
@@ -108,15 +125,35 @@ const observe = (run: () => void, watcher?: Watcher): void => {
108
125
  }
109
126
  }
110
127
 
128
+ /**
129
+ * Emit a notification to listeners
130
+ *
131
+ * @param {Set<Listener>} listeners - Listeners to be notified
132
+ * @param {Notifications[K]} payload - Payload to be sent to listeners
133
+ */
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)
141
+ }
142
+ }
143
+
111
144
  /* === Exports === */
112
145
 
113
146
  export {
114
147
  type Cleanup,
115
148
  type Watcher,
116
- subscribe,
117
- notify,
118
- flush,
119
- batch,
149
+ type Notifications,
150
+ type Listener,
151
+ type Listeners,
120
152
  createWatcher,
121
- observe,
153
+ subscribeActiveWatcher,
154
+ notifyWatchers,
155
+ flushPendingReactions,
156
+ batchSignalWrites,
157
+ trackSignalReads,
158
+ emitNotification,
122
159
  }
package/src/util.ts CHANGED
@@ -23,6 +23,15 @@ const isAsyncFunction = /*#__PURE__*/ <T>(
23
23
  ): fn is (...args: unknown[]) => Promise<T> =>
24
24
  isFunction(fn) && fn.constructor.name === 'AsyncFunction'
25
25
 
26
+ const isSyncFunction = /*#__PURE__*/ <T extends unknown & { then?: undefined }>(
27
+ fn: unknown,
28
+ ): fn is (...args: unknown[]) => T =>
29
+ isFunction(fn) && fn.constructor.name !== 'AsyncFunction'
30
+
31
+ const isNonNullObject = /*#__PURE__*/ (
32
+ value: unknown,
33
+ ): value is NonNullable<object> => value != null && typeof value === 'object'
34
+
26
35
  const isObjectOfType = /*#__PURE__*/ <T>(
27
36
  value: unknown,
28
37
  type: string,
@@ -38,17 +47,10 @@ const isRecordOrArray = /*#__PURE__*/ <
38
47
  value: unknown,
39
48
  ): value is T => isRecord(value) || Array.isArray(value)
40
49
 
41
- const validArrayIndexes = /*#__PURE__*/ (
42
- keys: Array<PropertyKey>,
43
- ): number[] | null => {
44
- if (!keys.length) return null
45
- const indexes = keys.map(k =>
46
- isString(k) ? parseInt(k, 10) : isNumber(k) ? k : NaN,
47
- )
48
- return indexes.every(index => Number.isFinite(index) && index >= 0)
49
- ? indexes.sort((a, b) => a - b)
50
- : null
51
- }
50
+ const isUniformArray = <T>(
51
+ value: unknown,
52
+ guard = (item: T): item is T & {} => item != null,
53
+ ): value is T[] => Array.isArray(value) && value.every(guard)
52
54
 
53
55
  const hasMethod = /*#__PURE__*/ <
54
56
  T extends object & Record<string, (...args: unknown[]) => unknown>,
@@ -64,23 +66,6 @@ const isAbortError = /*#__PURE__*/ (error: unknown): boolean =>
64
66
  const toError = /*#__PURE__*/ (reason: unknown): Error =>
65
67
  reason instanceof Error ? reason : Error(String(reason))
66
68
 
67
- const arrayToRecord = /*#__PURE__*/ <T>(array: T[]): Record<string, T> => {
68
- const record: Record<string, T> = {}
69
- for (let i = 0; i < array.length; i++) record[String(i)] = array[i]
70
- return record
71
- }
72
-
73
- const recordToArray = /*#__PURE__*/ <T>(
74
- record: Record<string | number, T>,
75
- ): Record<string, T> | T[] => {
76
- const indexes = validArrayIndexes(Object.keys(record))
77
- if (indexes === null) return record
78
-
79
- const array: T[] = []
80
- for (const index of indexes) array.push(record[String(index)])
81
- return array
82
- }
83
-
84
69
  const valueString = /*#__PURE__*/ (value: unknown): string =>
85
70
  isString(value)
86
71
  ? `"${value}"`
@@ -97,13 +82,14 @@ export {
97
82
  isSymbol,
98
83
  isFunction,
99
84
  isAsyncFunction,
85
+ isSyncFunction,
86
+ isNonNullObject,
100
87
  isObjectOfType,
101
88
  isRecord,
102
89
  isRecordOrArray,
90
+ isUniformArray,
103
91
  hasMethod,
104
92
  isAbortError,
105
93
  toError,
106
- arrayToRecord,
107
- recordToArray,
108
94
  valueString,
109
95
  }
@@ -1,38 +1,36 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import {
3
- batch,
4
- createComputed,
3
+ batchSignalWrites,
5
4
  createEffect,
6
- createState,
5
+ Memo,
7
6
  match,
8
7
  resolve,
9
- } from '..'
8
+ State,
9
+ } from '../index.ts'
10
10
 
11
11
  /* === Tests === */
12
12
 
13
13
  describe('Batch', () => {
14
14
  test('should be triggered only once after repeated state change', () => {
15
- const cause = createState(0)
15
+ const cause = new State(0)
16
16
  let result = 0
17
17
  let count = 0
18
18
  createEffect((): undefined => {
19
19
  result = cause.get()
20
20
  count++
21
21
  })
22
- batch(() => {
23
- for (let i = 1; i <= 10; i++) {
24
- cause.set(i)
25
- }
22
+ batchSignalWrites(() => {
23
+ for (let i = 1; i <= 10; i++) cause.set(i)
26
24
  })
27
25
  expect(result).toBe(10)
28
26
  expect(count).toBe(2) // + 1 for effect initialization
29
27
  })
30
28
 
31
29
  test('should be triggered only once when multiple signals are set', () => {
32
- const a = createState(3)
33
- const b = createState(4)
34
- const c = createState(5)
35
- const sum = createComputed(() => a.get() + b.get() + c.get())
30
+ const a = new State(3)
31
+ const b = new State(4)
32
+ const c = new State(5)
33
+ const sum = new Memo(() => a.get() + b.get() + c.get())
36
34
  let result = 0
37
35
  let count = 0
38
36
  createEffect(() => {
@@ -45,7 +43,7 @@ describe('Batch', () => {
45
43
  err: () => {},
46
44
  })
47
45
  })
48
- batch(() => {
46
+ batchSignalWrites(() => {
49
47
  a.set(6)
50
48
  b.set(8)
51
49
  c.set(10)
@@ -56,10 +54,10 @@ describe('Batch', () => {
56
54
 
57
55
  test('should prove example from README works', () => {
58
56
  // State: define an array of Signal<number>
59
- const signals = [createState(2), createState(3), createState(5)]
57
+ const signals = [new State(2), new State(3), new State(5)]
60
58
 
61
59
  // Computed: derive a calculation ...
62
- const sum = createComputed(() => {
60
+ const sum = new Memo(() => {
63
61
  const v = signals.reduce((total, v) => total + v.get(), 0)
64
62
  if (!Number.isFinite(v)) throw new Error('Invalid value')
65
63
  return v
@@ -89,7 +87,7 @@ describe('Batch', () => {
89
87
  expect(result).toBe(10)
90
88
 
91
89
  // Batch: apply changes to all signals in a single transaction
92
- batch(() => {
90
+ batchSignalWrites(() => {
93
91
  signals.forEach(signal => signal.update(v => v * 2))
94
92
  })
95
93
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, mock, test } from 'bun:test'
2
- import { batch, createComputed, createEffect, createState } from '..'
2
+ import { batchSignalWrites, 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
 
@@ -15,20 +15,20 @@ const busy = () => {
15
15
  const framework = {
16
16
  name: 'Cause & Effect',
17
17
  signal: <T extends {}>(initialValue: T) => {
18
- const s = createState<T>(initialValue)
18
+ const s = new State<T>(initialValue)
19
19
  return {
20
20
  write: (v: T) => s.set(v),
21
21
  read: () => s.get(),
22
22
  }
23
23
  },
24
24
  computed: <T extends {}>(fn: () => T) => {
25
- const c = createComputed(fn)
25
+ const c = new Memo(fn)
26
26
  return {
27
27
  read: () => c.get(),
28
28
  }
29
29
  },
30
30
  effect: (fn: () => undefined) => createEffect(fn),
31
- withBatch: (fn: () => undefined) => batch(fn),
31
+ withBatch: (fn: () => undefined) => batchSignalWrites(fn),
32
32
  withBuild: <T>(fn: () => T) => fn(),
33
33
  }
34
34
  const testPullCounts = true