@zeix/cause-effect 0.17.3 → 0.18.1

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 (94) hide show
  1. package/.ai-context.md +169 -227
  2. package/.cursorrules +41 -35
  3. package/.github/copilot-instructions.md +176 -116
  4. package/ARCHITECTURE.md +276 -0
  5. package/CHANGELOG.md +29 -0
  6. package/CLAUDE.md +201 -143
  7. package/GUIDE.md +298 -0
  8. package/README.md +246 -193
  9. package/REQUIREMENTS.md +100 -0
  10. package/bench/reactivity.bench.ts +577 -0
  11. package/context7.json +4 -0
  12. package/examples/events-sensor.ts +187 -0
  13. package/examples/selector-sensor.ts +173 -0
  14. package/index.dev.js +1390 -1008
  15. package/index.js +1 -1
  16. package/index.ts +60 -74
  17. package/package.json +5 -2
  18. package/skills/changelog-keeper/SKILL.md +59 -0
  19. package/skills/changelog-keeper/agents/openai.yaml +4 -0
  20. package/src/errors.ts +118 -74
  21. package/src/graph.ts +612 -0
  22. package/src/nodes/collection.ts +512 -0
  23. package/src/nodes/effect.ts +149 -0
  24. package/src/nodes/list.ts +589 -0
  25. package/src/nodes/memo.ts +148 -0
  26. package/src/nodes/sensor.ts +149 -0
  27. package/src/nodes/state.ts +135 -0
  28. package/src/nodes/store.ts +378 -0
  29. package/src/nodes/task.ts +174 -0
  30. package/src/signal.ts +112 -66
  31. package/src/util.ts +26 -57
  32. package/test/batch.test.ts +96 -62
  33. package/test/benchmark.test.ts +473 -487
  34. package/test/collection.test.ts +456 -707
  35. package/test/effect.test.ts +293 -696
  36. package/test/list.test.ts +335 -592
  37. package/test/memo.test.ts +574 -0
  38. package/test/regression.test.ts +156 -0
  39. package/test/scope.test.ts +191 -0
  40. package/test/sensor.test.ts +454 -0
  41. package/test/signal.test.ts +220 -213
  42. package/test/state.test.ts +217 -265
  43. package/test/store.test.ts +346 -446
  44. package/test/task.test.ts +529 -0
  45. package/test/untrack.test.ts +167 -0
  46. package/types/index.d.ts +13 -15
  47. package/types/src/errors.d.ts +73 -17
  48. package/types/src/graph.d.ts +218 -0
  49. package/types/src/nodes/collection.d.ts +69 -0
  50. package/types/src/nodes/effect.d.ts +48 -0
  51. package/types/src/nodes/list.d.ts +66 -0
  52. package/types/src/nodes/memo.d.ts +63 -0
  53. package/types/src/nodes/sensor.d.ts +81 -0
  54. package/types/src/nodes/state.d.ts +78 -0
  55. package/types/src/nodes/store.d.ts +51 -0
  56. package/types/src/nodes/task.d.ts +79 -0
  57. package/types/src/signal.d.ts +43 -29
  58. package/types/src/util.d.ts +9 -16
  59. package/archive/benchmark.ts +0 -683
  60. package/archive/collection.ts +0 -253
  61. package/archive/composite.ts +0 -85
  62. package/archive/computed.ts +0 -195
  63. package/archive/list.ts +0 -483
  64. package/archive/memo.ts +0 -139
  65. package/archive/state.ts +0 -90
  66. package/archive/store.ts +0 -298
  67. package/archive/task.ts +0 -189
  68. package/src/classes/collection.ts +0 -245
  69. package/src/classes/computed.ts +0 -349
  70. package/src/classes/list.ts +0 -343
  71. package/src/classes/ref.ts +0 -70
  72. package/src/classes/state.ts +0 -102
  73. package/src/classes/store.ts +0 -262
  74. package/src/diff.ts +0 -138
  75. package/src/effect.ts +0 -93
  76. package/src/match.ts +0 -45
  77. package/src/resolve.ts +0 -49
  78. package/src/system.ts +0 -257
  79. package/test/computed.test.ts +0 -1108
  80. package/test/diff.test.ts +0 -955
  81. package/test/match.test.ts +0 -388
  82. package/test/ref.test.ts +0 -353
  83. package/test/resolve.test.ts +0 -154
  84. package/types/src/classes/collection.d.ts +0 -45
  85. package/types/src/classes/computed.d.ts +0 -94
  86. package/types/src/classes/list.d.ts +0 -43
  87. package/types/src/classes/ref.d.ts +0 -35
  88. package/types/src/classes/state.d.ts +0 -49
  89. package/types/src/classes/store.d.ts +0 -52
  90. package/types/src/diff.d.ts +0 -28
  91. package/types/src/effect.d.ts +0 -15
  92. package/types/src/match.d.ts +0 -21
  93. package/types/src/resolve.d.ts +0 -29
  94. package/types/src/system.d.ts +0 -78
@@ -1,262 +0,0 @@
1
- import { type DiffResult, diff, type UnknownRecord } from '../diff'
2
- import {
3
- DuplicateKeyError,
4
- guardMutableSignal,
5
- validateSignalValue,
6
- } from '../errors'
7
- import { createMutableSignal, type MutableSignal } from '../signal'
8
- import {
9
- batch,
10
- notifyOf,
11
- registerWatchCallbacks,
12
- type SignalOptions,
13
- subscribeTo,
14
- UNSET,
15
- unsubscribeAllFrom,
16
- } from '../system'
17
- import { isFunction, isObjectOfType, isRecord, isSymbol } from '../util'
18
- import type { List } from './list'
19
- import type { State } from './state'
20
-
21
- /* === Types === */
22
-
23
- type Store<T extends UnknownRecord> = BaseStore<T> & {
24
- [K in keyof T]: T[K] extends readonly (infer U extends {})[]
25
- ? List<U>
26
- : T[K] extends UnknownRecord
27
- ? Store<T[K]>
28
- : State<T[K] & {}>
29
- }
30
-
31
- /* === Constants === */
32
-
33
- const TYPE_STORE = 'Store' as const
34
-
35
- /* === Store Implementation === */
36
-
37
- /**
38
- * Create a new store with the given initial value.
39
- *
40
- * @since 0.17.0
41
- * @param {T} initialValue - The initial value of the store
42
- * @throws {NullishSignalValueError} - If the initial value is null or undefined
43
- * @throws {InvalidSignalValueError} - If the initial value is not an object
44
- */
45
- class BaseStore<T extends UnknownRecord> {
46
- #signals = new Map<keyof T & string, MutableSignal<T[keyof T] & {}>>()
47
-
48
- constructor(initialValue: T, options?: SignalOptions<T>) {
49
- validateSignalValue(
50
- TYPE_STORE,
51
- initialValue,
52
- options?.guard ?? isRecord,
53
- )
54
-
55
- this.#change({
56
- add: initialValue,
57
- change: {},
58
- remove: {},
59
- changed: true,
60
- })
61
- if (options?.watched)
62
- registerWatchCallbacks(this, options.watched, options.unwatched)
63
- }
64
-
65
- get #value(): T {
66
- const record = {} as UnknownRecord
67
- for (const [key, signal] of this.#signals.entries())
68
- record[key] = signal.get()
69
- return record as T
70
- }
71
-
72
- #validate<K extends keyof T & string>(
73
- key: K,
74
- value: unknown,
75
- ): value is T[K] & {} {
76
- validateSignalValue(`${TYPE_STORE} for key "${key}"`, value)
77
- return true
78
- }
79
-
80
- #add<K extends keyof T & string>(key: K, value: T[K]): boolean {
81
- if (!this.#validate(key, value)) return false
82
-
83
- this.#signals.set(
84
- key,
85
- createMutableSignal(value) as unknown as MutableSignal<
86
- T[keyof T] & {}
87
- >,
88
- )
89
- return true
90
- }
91
-
92
- #change(changes: DiffResult): boolean {
93
- // Additions
94
- if (Object.keys(changes.add).length) {
95
- for (const key in changes.add)
96
- this.#add(
97
- key,
98
- changes.add[key] as T[Extract<keyof T, string>] & {},
99
- )
100
- }
101
-
102
- // Changes
103
- if (Object.keys(changes.change).length) {
104
- batch(() => {
105
- for (const key in changes.change) {
106
- const value = changes.change[key]
107
- if (!this.#validate(key, value)) continue
108
-
109
- const signal = this.#signals.get(key)
110
- if (guardMutableSignal(`list item "${key}"`, value, signal))
111
- signal.set(value)
112
- }
113
- })
114
- }
115
-
116
- // Removals
117
- if (Object.keys(changes.remove).length) {
118
- for (const key in changes.remove) this.remove(key)
119
- }
120
-
121
- return changes.changed
122
- }
123
-
124
- // Public methods
125
- get [Symbol.toStringTag](): 'Store' {
126
- return TYPE_STORE
127
- }
128
-
129
- get [Symbol.isConcatSpreadable](): boolean {
130
- return false
131
- }
132
-
133
- *[Symbol.iterator](): IterableIterator<
134
- [string, MutableSignal<T[keyof T] & {}>]
135
- > {
136
- for (const [key, signal] of this.#signals.entries()) yield [key, signal]
137
- }
138
-
139
- keys(): IterableIterator<string> {
140
- subscribeTo(this)
141
- return this.#signals.keys()
142
- }
143
-
144
- byKey<K extends keyof T & string>(
145
- key: K,
146
- ): T[K] extends readonly (infer U extends {})[]
147
- ? List<U>
148
- : T[K] extends UnknownRecord
149
- ? Store<T[K]>
150
- : T[K] extends unknown & {}
151
- ? State<T[K] & {}>
152
- : State<T[K] & {}> | undefined {
153
- return this.#signals.get(key) as T[K] extends readonly (infer U extends
154
- {})[]
155
- ? List<U>
156
- : T[K] extends UnknownRecord
157
- ? Store<T[K]>
158
- : T[K] extends unknown & {}
159
- ? State<T[K] & {}>
160
- : State<T[K] & {}> | undefined
161
- }
162
-
163
- get(): T {
164
- subscribeTo(this)
165
- return this.#value
166
- }
167
-
168
- set(newValue: T): void {
169
- if (UNSET === newValue) {
170
- this.#signals.clear()
171
- notifyOf(this)
172
- unsubscribeAllFrom(this)
173
- return
174
- }
175
-
176
- const changed = this.#change(diff(this.#value, newValue))
177
- if (changed) notifyOf(this)
178
- }
179
-
180
- update(fn: (oldValue: T) => T): void {
181
- this.set(fn(this.get()))
182
- }
183
-
184
- add<K extends keyof T & string>(key: K, value: T[K]): K {
185
- if (this.#signals.has(key))
186
- throw new DuplicateKeyError(TYPE_STORE, key, value)
187
-
188
- const ok = this.#add(key, value)
189
- if (ok) notifyOf(this)
190
- return key
191
- }
192
-
193
- remove(key: string): void {
194
- const ok = this.#signals.delete(key)
195
- if (ok) notifyOf(this)
196
- }
197
- }
198
-
199
- /* === Functions === */
200
-
201
- /**
202
- * Create a new store with deeply nested reactive properties
203
- *
204
- * @since 0.15.0
205
- * @param {T} initialValue - Initial object or array value of the store
206
- * @param {SignalOptions<T>} options - Options for the store
207
- * @returns {Store<T>} - New store with reactive properties that preserves the original type T
208
- */
209
- const createStore = <T extends UnknownRecord>(
210
- initialValue: T,
211
- options?: SignalOptions<T>,
212
- ): Store<T> => {
213
- const instance = new BaseStore(initialValue, options)
214
-
215
- // Return proxy for property access
216
- return new Proxy(instance, {
217
- get(target, prop) {
218
- if (prop in target) {
219
- const value = Reflect.get(target, prop)
220
- return isFunction(value) ? value.bind(target) : value
221
- }
222
- if (!isSymbol(prop)) return target.byKey(prop)
223
- },
224
- has(target, prop) {
225
- if (prop in target) return true
226
- return target.byKey(String(prop)) !== undefined
227
- },
228
- ownKeys(target) {
229
- return Array.from(target.keys())
230
- },
231
- getOwnPropertyDescriptor(target, prop) {
232
- if (prop in target)
233
- return Reflect.getOwnPropertyDescriptor(target, prop)
234
- if (isSymbol(prop)) return undefined
235
-
236
- const signal = target.byKey(String(prop))
237
- return signal
238
- ? {
239
- enumerable: true,
240
- configurable: true,
241
- writable: true,
242
- value: signal,
243
- }
244
- : undefined
245
- },
246
- }) as Store<T>
247
- }
248
-
249
- /**
250
- * Check if the provided value is a Store instance
251
- *
252
- * @since 0.15.0
253
- * @param {unknown} value - Value to check
254
- * @returns {boolean} - True if the value is a Store instance, false otherwise
255
- */
256
- const isStore = <T extends UnknownRecord>(
257
- value: unknown,
258
- ): value is BaseStore<T> => isObjectOfType(value, TYPE_STORE)
259
-
260
- /* === Exports === */
261
-
262
- export { createStore, isStore, BaseStore, TYPE_STORE, type Store }
package/src/diff.ts DELETED
@@ -1,138 +0,0 @@
1
- import { CircularDependencyError } from './errors'
2
- import { UNSET } from './system'
3
- import { isNonNullObject, isRecord, isRecordOrArray } from './util'
4
-
5
- /* === Types === */
6
-
7
- type UnknownRecord = Record<string, unknown>
8
- type UnknownArray = ReadonlyArray<unknown & {}>
9
-
10
- type DiffResult = {
11
- changed: boolean
12
- add: UnknownRecord
13
- change: UnknownRecord
14
- remove: UnknownRecord
15
- }
16
-
17
- /* === Functions === */
18
-
19
- /**
20
- * Checks if two values are equal with cycle detection
21
- *
22
- * @since 0.15.0
23
- * @param {T} a - First value to compare
24
- * @param {T} b - Second value to compare
25
- * @param {WeakSet<object>} visited - Set to track visited objects for cycle detection
26
- * @returns {boolean} Whether the two values are equal
27
- */
28
- const isEqual = <T>(a: T, b: T, visited?: WeakSet<object>): boolean => {
29
- // Fast paths
30
- if (Object.is(a, b)) return true
31
- if (typeof a !== typeof b) return false
32
- if (!isNonNullObject(a) || !isNonNullObject(b)) return false
33
-
34
- // Cycle detection
35
- if (!visited) visited = new WeakSet()
36
- if (visited.has(a as object) || visited.has(b as object))
37
- throw new CircularDependencyError('isEqual')
38
- visited.add(a)
39
- visited.add(b)
40
-
41
- try {
42
- if (Array.isArray(a) && Array.isArray(b)) {
43
- if (a.length !== b.length) return false
44
- for (let i = 0; i < a.length; i++) {
45
- if (!isEqual(a[i], b[i], visited)) return false
46
- }
47
- return true
48
- }
49
-
50
- if (Array.isArray(a) !== Array.isArray(b)) return false
51
-
52
- if (isRecord(a) && isRecord(b)) {
53
- const aKeys = Object.keys(a)
54
- const bKeys = Object.keys(b)
55
-
56
- if (aKeys.length !== bKeys.length) return false
57
- for (const key of aKeys) {
58
- if (!(key in b)) return false
59
- if (!isEqual(a[key], b[key], visited)) return false
60
- }
61
- return true
62
- }
63
-
64
- // For non-records/non-arrays, they are only equal if they are the same reference
65
- // (which would have been caught by Object.is at the beginning)
66
- return false
67
- } finally {
68
- visited.delete(a)
69
- visited.delete(b)
70
- }
71
- }
72
-
73
- /**
74
- * Compares two records and returns a result object containing the differences.
75
- *
76
- * @since 0.15.0
77
- * @param {T} oldObj - The old record to compare
78
- * @param {T} newObj - The new record to compare
79
- * @returns {DiffResult} The result of the comparison
80
- */
81
- const diff = <T extends UnknownRecord>(oldObj: T, newObj: T): DiffResult => {
82
- // Guard against non-objects that can't be diffed properly with Object.keys and 'in' operator
83
- const oldValid = isRecordOrArray(oldObj)
84
- const newValid = isRecordOrArray(newObj)
85
- if (!oldValid || !newValid) {
86
- // For non-objects or non-plain objects, treat as complete change if different
87
- const changed = !Object.is(oldObj, newObj)
88
- return {
89
- changed,
90
- add: changed && newValid ? newObj : {},
91
- change: {},
92
- remove: changed && oldValid ? oldObj : {},
93
- }
94
- }
95
-
96
- const visited = new WeakSet()
97
-
98
- const add = {} as UnknownRecord
99
- const change = {} as UnknownRecord
100
- const remove = {} as UnknownRecord
101
-
102
- const oldKeys = Object.keys(oldObj)
103
- const newKeys = Object.keys(newObj)
104
- const allKeys = new Set([...oldKeys, ...newKeys])
105
-
106
- for (const key of allKeys) {
107
- const oldHas = key in oldObj
108
- const newHas = key in newObj
109
-
110
- if (!oldHas && newHas) {
111
- add[key] = newObj[key]
112
- continue
113
- } else if (oldHas && !newHas) {
114
- remove[key] = UNSET
115
- continue
116
- }
117
-
118
- const oldValue = oldObj[key]
119
- const newValue = newObj[key]
120
-
121
- if (!isEqual(oldValue, newValue, visited)) change[key] = newValue
122
- }
123
-
124
- return {
125
- add,
126
- change,
127
- remove,
128
- changed: !!(
129
- Object.keys(add).length ||
130
- Object.keys(change).length ||
131
- Object.keys(remove).length
132
- ),
133
- }
134
- }
135
-
136
- /* === Exports === */
137
-
138
- export { type DiffResult, diff, isEqual, type UnknownRecord, type UnknownArray }
package/src/effect.ts DELETED
@@ -1,93 +0,0 @@
1
- import { CircularDependencyError, InvalidCallbackError } from './errors'
2
- import { type Cleanup, createWatcher, type MaybeCleanup } from './system'
3
- import { isAbortError, isAsyncFunction, isFunction } from './util'
4
-
5
- /* === Types === */
6
-
7
- type EffectCallback =
8
- | (() => MaybeCleanup)
9
- | ((abort: AbortSignal) => Promise<MaybeCleanup>)
10
-
11
- /* === Functions === */
12
-
13
- /**
14
- * Define what happens when a reactive state changes
15
- *
16
- * The callback can be synchronous or asynchronous. Async callbacks receive
17
- * an AbortSignal parameter, which is automatically aborted when the effect
18
- * re-runs or is cleaned up, preventing stale async operations.
19
- *
20
- * @since 0.1.0
21
- * @param {EffectCallback} callback - Synchronous or asynchronous effect callback
22
- * @returns {Cleanup} - Cleanup function for the effect
23
- */
24
- const createEffect = (callback: EffectCallback): Cleanup => {
25
- if (!isFunction(callback) || callback.length > 1)
26
- throw new InvalidCallbackError('effect', callback)
27
-
28
- const isAsync = isAsyncFunction(callback)
29
- let running = false
30
- let controller: AbortController | undefined
31
-
32
- const watcher = createWatcher(
33
- () => {
34
- watcher.run()
35
- },
36
- () => {
37
- if (running) throw new CircularDependencyError('effect')
38
- running = true
39
-
40
- // Abort any previous async operations
41
- controller?.abort()
42
- controller = undefined
43
-
44
- let cleanup: MaybeCleanup | Promise<MaybeCleanup>
45
-
46
- try {
47
- if (isAsync) {
48
- // Create AbortController for async callback
49
- controller = new AbortController()
50
- const currentController = controller
51
- callback(controller.signal)
52
- .then(cleanup => {
53
- // Only register cleanup if this is still the current controller
54
- if (
55
- isFunction(cleanup) &&
56
- controller === currentController
57
- )
58
- watcher.onCleanup(cleanup)
59
- })
60
- .catch(error => {
61
- if (!isAbortError(error))
62
- console.error(
63
- 'Error in async effect callback:',
64
- error,
65
- )
66
- })
67
- } else {
68
- cleanup = callback()
69
- if (isFunction(cleanup)) watcher.onCleanup(cleanup)
70
- }
71
- } catch (error) {
72
- if (!isAbortError(error))
73
- console.error('Error in effect callback:', error)
74
- }
75
-
76
- running = false
77
- },
78
- )
79
-
80
- watcher()
81
- return () => {
82
- controller?.abort()
83
- try {
84
- watcher.stop()
85
- } catch (error) {
86
- console.error('Error in effect cleanup:', error)
87
- }
88
- }
89
- }
90
-
91
- /* === Exports === */
92
-
93
- export { type MaybeCleanup, type EffectCallback, createEffect }
package/src/match.ts DELETED
@@ -1,45 +0,0 @@
1
- import { createError } from './errors'
2
- import type { ResolveResult } from './resolve'
3
- import type { SignalValues, UnknownSignalRecord } from './signal'
4
-
5
- /* === Types === */
6
-
7
- type MatchHandlers<S extends UnknownSignalRecord> = {
8
- ok: (values: SignalValues<S>) => void
9
- err?: (errors: readonly Error[]) => void
10
- nil?: () => void
11
- }
12
-
13
- /* === Functions === */
14
-
15
- /**
16
- * Match on resolve result and call appropriate handler for side effects
17
- *
18
- * This is a utility function for those who prefer the handler pattern.
19
- * All handlers are for side effects only and return void. If you need
20
- * cleanup logic, use a hoisted let variable in your effect.
21
- *
22
- * @since 0.15.0
23
- * @param {ResolveResult<S>} result - Result from resolve()
24
- * @param {MatchHandlers<S>} handlers - Handlers for different states (side effects only)
25
- * @returns {void} - Always returns void
26
- */
27
- function match<S extends UnknownSignalRecord>(
28
- result: ResolveResult<S>,
29
- handlers: MatchHandlers<S>,
30
- ): void {
31
- try {
32
- if (result.pending) handlers.nil?.()
33
- else if (result.errors) handlers.err?.(result.errors)
34
- else if (result.ok) handlers.ok(result.values)
35
- } catch (e) {
36
- const error = createError(e)
37
- if (handlers.err && (!result.errors || !result.errors.includes(error)))
38
- handlers.err(result.errors ? [...result.errors, error] : [error])
39
- else throw error
40
- }
41
- }
42
-
43
- /* === Exports === */
44
-
45
- export { match, type MatchHandlers }
package/src/resolve.ts DELETED
@@ -1,49 +0,0 @@
1
- import type { UnknownRecord } from './diff'
2
- import { createError } from './errors'
3
- import type { SignalValues, UnknownSignalRecord } from './signal'
4
- import { UNSET } from './system'
5
-
6
- /* === Types === */
7
-
8
- type ResolveResult<S extends UnknownSignalRecord> =
9
- | { ok: true; values: SignalValues<S>; errors?: never; pending?: never }
10
- | { ok: false; errors: readonly Error[]; values?: never; pending?: never }
11
- | { ok: false; pending: true; values?: never; errors?: never }
12
-
13
- /* === Functions === */
14
-
15
- /**
16
- * Resolve signal values with perfect type inference
17
- *
18
- * Always returns a discriminated union result, regardless of whether
19
- * handlers are provided or not. This ensures a predictable API.
20
- *
21
- * @since 0.15.0
22
- * @param {S} signals - Signals to resolve
23
- * @returns {ResolveResult<S>} - Discriminated union result
24
- */
25
- function resolve<S extends UnknownSignalRecord>(signals: S): ResolveResult<S> {
26
- const errors: Error[] = []
27
- let pending = false
28
- const values: UnknownRecord = {}
29
-
30
- // Collect values and errors
31
- for (const [key, signal] of Object.entries(signals)) {
32
- try {
33
- const value = signal.get()
34
- if (value === UNSET) pending = true
35
- else values[key] = value
36
- } catch (e) {
37
- errors.push(createError(e))
38
- }
39
- }
40
-
41
- // Return discriminated union
42
- if (pending) return { ok: false, pending: true }
43
- if (errors.length > 0) return { ok: false, errors }
44
- return { ok: true, values: values as SignalValues<S> }
45
- }
46
-
47
- /* === Exports === */
48
-
49
- export { resolve, type ResolveResult }