@zeix/cause-effect 0.17.3 → 0.18.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 (89) hide show
  1. package/.ai-context.md +163 -232
  2. package/.cursorrules +41 -35
  3. package/.github/copilot-instructions.md +166 -116
  4. package/ARCHITECTURE.md +274 -0
  5. package/CLAUDE.md +199 -143
  6. package/COLLECTION_REFACTORING.md +161 -0
  7. package/GUIDE.md +298 -0
  8. package/README.md +232 -197
  9. package/REQUIREMENTS.md +100 -0
  10. package/bench/reactivity.bench.ts +577 -0
  11. package/index.dev.js +1325 -997
  12. package/index.js +1 -1
  13. package/index.ts +58 -74
  14. package/package.json +4 -1
  15. package/src/errors.ts +118 -74
  16. package/src/graph.ts +601 -0
  17. package/src/nodes/collection.ts +474 -0
  18. package/src/nodes/effect.ts +149 -0
  19. package/src/nodes/list.ts +588 -0
  20. package/src/nodes/memo.ts +120 -0
  21. package/src/nodes/sensor.ts +139 -0
  22. package/src/nodes/state.ts +135 -0
  23. package/src/nodes/store.ts +383 -0
  24. package/src/nodes/task.ts +146 -0
  25. package/src/signal.ts +112 -66
  26. package/src/util.ts +26 -57
  27. package/test/batch.test.ts +96 -62
  28. package/test/benchmark.test.ts +473 -487
  29. package/test/collection.test.ts +466 -706
  30. package/test/effect.test.ts +293 -696
  31. package/test/list.test.ts +335 -592
  32. package/test/memo.test.ts +380 -0
  33. package/test/regression.test.ts +156 -0
  34. package/test/scope.test.ts +191 -0
  35. package/test/sensor.test.ts +454 -0
  36. package/test/signal.test.ts +220 -213
  37. package/test/state.test.ts +217 -265
  38. package/test/store.test.ts +346 -446
  39. package/test/task.test.ts +395 -0
  40. package/test/untrack.test.ts +167 -0
  41. package/types/index.d.ts +13 -15
  42. package/types/src/errors.d.ts +73 -17
  43. package/types/src/graph.d.ts +208 -0
  44. package/types/src/nodes/collection.d.ts +64 -0
  45. package/types/src/nodes/effect.d.ts +48 -0
  46. package/types/src/nodes/list.d.ts +65 -0
  47. package/types/src/nodes/memo.d.ts +57 -0
  48. package/types/src/nodes/sensor.d.ts +75 -0
  49. package/types/src/nodes/state.d.ts +78 -0
  50. package/types/src/nodes/store.d.ts +51 -0
  51. package/types/src/nodes/task.d.ts +73 -0
  52. package/types/src/signal.d.ts +43 -29
  53. package/types/src/util.d.ts +9 -16
  54. package/archive/benchmark.ts +0 -683
  55. package/archive/collection.ts +0 -253
  56. package/archive/composite.ts +0 -85
  57. package/archive/computed.ts +0 -195
  58. package/archive/list.ts +0 -483
  59. package/archive/memo.ts +0 -139
  60. package/archive/state.ts +0 -90
  61. package/archive/store.ts +0 -298
  62. package/archive/task.ts +0 -189
  63. package/src/classes/collection.ts +0 -245
  64. package/src/classes/computed.ts +0 -349
  65. package/src/classes/list.ts +0 -343
  66. package/src/classes/ref.ts +0 -70
  67. package/src/classes/state.ts +0 -102
  68. package/src/classes/store.ts +0 -262
  69. package/src/diff.ts +0 -138
  70. package/src/effect.ts +0 -93
  71. package/src/match.ts +0 -45
  72. package/src/resolve.ts +0 -49
  73. package/src/system.ts +0 -257
  74. package/test/computed.test.ts +0 -1108
  75. package/test/diff.test.ts +0 -955
  76. package/test/match.test.ts +0 -388
  77. package/test/ref.test.ts +0 -353
  78. package/test/resolve.test.ts +0 -154
  79. package/types/src/classes/collection.d.ts +0 -45
  80. package/types/src/classes/computed.d.ts +0 -94
  81. package/types/src/classes/list.d.ts +0 -43
  82. package/types/src/classes/ref.d.ts +0 -35
  83. package/types/src/classes/state.d.ts +0 -49
  84. package/types/src/classes/store.d.ts +0 -52
  85. package/types/src/diff.d.ts +0 -28
  86. package/types/src/effect.d.ts +0 -15
  87. package/types/src/match.d.ts +0 -21
  88. package/types/src/resolve.d.ts +0 -29
  89. 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 }