@zeix/cause-effect 0.14.2 → 0.15.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.
package/src/diff.ts ADDED
@@ -0,0 +1,148 @@
1
+ import { UNSET } from './signal'
2
+ import { CircularDependencyError, isRecord } from './util'
3
+
4
+ /* === Types === */
5
+
6
+ type UnknownRecord = Record<string, unknown & {}>
7
+ type UnknownRecordOrArray = {
8
+ [x: string | number]: unknown & {}
9
+ }
10
+
11
+ type DiffResult<T extends UnknownRecordOrArray = UnknownRecord> = {
12
+ changed: boolean
13
+ add: Partial<T>
14
+ change: Partial<T>
15
+ remove: Partial<T>
16
+ }
17
+
18
+ /* === Functions === */
19
+
20
+ /**
21
+ * Checks if two values are equal with cycle detection
22
+ *
23
+ * @since 0.15.0
24
+ * @param {T} a - First value to compare
25
+ * @param {T} b - Second value to compare
26
+ * @param {WeakSet<object>} visited - Set to track visited objects for cycle detection
27
+ * @returns {boolean} Whether the two values are equal
28
+ */
29
+ const isEqual = <T>(a: T, b: T, visited?: WeakSet<object>): boolean => {
30
+ // Fast paths
31
+ if (Object.is(a, b)) return true
32
+ if (typeof a !== typeof b) return false
33
+ if (typeof a !== 'object' || a === null || b === null) return false
34
+
35
+ // Cycle detection
36
+ if (!visited) visited = new WeakSet()
37
+ if (visited.has(a as object) || visited.has(b as object))
38
+ throw new CircularDependencyError('isEqual')
39
+ visited.add(a as object)
40
+ visited.add(b as object)
41
+
42
+ try {
43
+ if (Array.isArray(a) && Array.isArray(b)) {
44
+ if (a.length !== b.length) return false
45
+ for (let i = 0; i < a.length; i++) {
46
+ if (!isEqual(a[i], b[i], visited)) return false
47
+ }
48
+ return true
49
+ }
50
+
51
+ if (Array.isArray(a) !== Array.isArray(b)) return false
52
+
53
+ if (isRecord(a) && isRecord(b)) {
54
+ const aKeys = Object.keys(a)
55
+ const bKeys = Object.keys(b)
56
+
57
+ if (aKeys.length !== bKeys.length) return false
58
+ for (const key of aKeys) {
59
+ if (!(key in b)) return false
60
+ if (
61
+ !isEqual(
62
+ (a as Record<string, unknown>)[key],
63
+ (b as Record<string, unknown>)[key],
64
+ visited,
65
+ )
66
+ )
67
+ return false
68
+ }
69
+ return true
70
+ }
71
+
72
+ return false
73
+ } finally {
74
+ visited.delete(a as object)
75
+ visited.delete(b as object)
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Compares two records and returns a result object containing the differences.
81
+ *
82
+ * @since 0.15.0
83
+ * @param {T} oldObj - The old record to compare
84
+ * @param {T} newObj - The new record to compare
85
+ * @returns {DiffResult<T>} The result of the comparison
86
+ */
87
+ const diff = <T extends UnknownRecordOrArray>(
88
+ oldObj: T,
89
+ newObj: T,
90
+ ): DiffResult<T> => {
91
+ const visited = new WeakSet<object>()
92
+
93
+ const diffRecords = (
94
+ oldRecord: Record<string, unknown>,
95
+ newRecord: Record<string, unknown>,
96
+ ): DiffResult<T> => {
97
+ const add: Partial<T> = {}
98
+ const change: Partial<T> = {}
99
+ const remove: Partial<T> = {}
100
+
101
+ const oldKeys = Object.keys(oldRecord)
102
+ const newKeys = Object.keys(newRecord)
103
+ const allKeys = new Set([...oldKeys, ...newKeys])
104
+
105
+ for (const key of allKeys) {
106
+ const oldHas = key in oldRecord
107
+ const newHas = key in newRecord
108
+
109
+ if (!oldHas && newHas) {
110
+ add[key as keyof T] = newRecord[key] as T[keyof T]
111
+ continue
112
+ } else if (oldHas && !newHas) {
113
+ remove[key as keyof T] = UNSET
114
+ continue
115
+ }
116
+
117
+ const oldValue = oldRecord[key] as T[keyof T]
118
+ const newValue = newRecord[key] as T[keyof T]
119
+
120
+ if (!isEqual(oldValue, newValue, visited))
121
+ change[key as keyof T] = newValue
122
+ }
123
+
124
+ const changed =
125
+ Object.keys(add).length > 0 ||
126
+ Object.keys(change).length > 0 ||
127
+ Object.keys(remove).length > 0
128
+
129
+ return {
130
+ changed,
131
+ add,
132
+ change,
133
+ remove,
134
+ }
135
+ }
136
+
137
+ return diffRecords(oldObj, newObj)
138
+ }
139
+
140
+ /* === Exports === */
141
+
142
+ export {
143
+ type DiffResult,
144
+ diff,
145
+ isEqual,
146
+ type UnknownRecord,
147
+ type UnknownRecordOrArray,
148
+ }
package/src/effect.ts CHANGED
@@ -1,80 +1,87 @@
1
1
  import { type Cleanup, observe, watch } from './scheduler'
2
- import { type Signal, type SignalValues, UNSET } from './signal'
3
- import { CircularDependencyError, isFunction, toError } from './util'
2
+ import {
3
+ CircularDependencyError,
4
+ isAbortError,
5
+ isAsyncFunction,
6
+ isFunction,
7
+ } from './util'
4
8
 
5
9
  /* === Types === */
6
10
 
7
- type EffectMatcher<S extends Signal<unknown & {}>[]> = {
8
- signals: S
9
- ok: (...values: SignalValues<S>) => Cleanup | undefined
10
- err?: (...errors: Error[]) => Cleanup | undefined
11
- nil?: () => Cleanup | undefined
12
- }
11
+ // biome-ignore lint/suspicious/noConfusingVoidType: optional Cleanup return type
12
+ type MaybeCleanup = Cleanup | undefined | void
13
+
14
+ type EffectCallback =
15
+ | (() => MaybeCleanup)
16
+ | ((abort: AbortSignal) => Promise<MaybeCleanup>)
13
17
 
14
18
  /* === Functions === */
15
19
 
16
20
  /**
17
21
  * Define what happens when a reactive state changes
18
22
  *
23
+ * The callback can be synchronous or asynchronous. Async callbacks receive
24
+ * an AbortSignal parameter, which is automatically aborted when the effect
25
+ * re-runs or is cleaned up, preventing stale async operations.
26
+ *
19
27
  * @since 0.1.0
20
- * @param {EffectMatcher<S> | (() => Cleanup | undefined)} matcher - effect matcher or callback
21
- * @returns {Cleanup} - cleanup function for the effect
28
+ * @param {EffectCallback} callback - Synchronous or asynchronous effect callback
29
+ * @returns {Cleanup} - Cleanup function for the effect
22
30
  */
23
- function effect<S extends Signal<unknown & {}>[]>(
24
- matcher: EffectMatcher<S> | (() => Cleanup | undefined),
25
- ): Cleanup {
26
- const {
27
- signals,
28
- ok,
29
- err = (error: Error): undefined => {
30
- console.error(error)
31
- },
32
- nil = (): undefined => {},
33
- } = isFunction(matcher)
34
- ? { signals: [] as unknown as S, ok: matcher }
35
- : matcher
36
-
31
+ const effect = (callback: EffectCallback): Cleanup => {
32
+ const isAsync = isAsyncFunction<MaybeCleanup>(callback)
37
33
  let running = false
34
+ let controller: AbortController | undefined
35
+
38
36
  const run = watch(() =>
39
37
  observe(() => {
40
38
  if (running) throw new CircularDependencyError('effect')
41
39
  running = true
42
40
 
43
- // Pure part
44
- const errors: Error[] = []
45
- let pending = false
46
- const values = signals.map(signal => {
47
- try {
48
- const value = signal.get()
49
- if (value === UNSET) pending = true
50
- return value
51
- } catch (e) {
52
- errors.push(toError(e))
53
- return UNSET
54
- }
55
- }) as SignalValues<S>
41
+ // Abort any previous async operations
42
+ controller?.abort()
43
+ controller = undefined
44
+
45
+ let cleanup: MaybeCleanup | Promise<MaybeCleanup>
56
46
 
57
- // Effectful part
58
- let cleanup: Cleanup | undefined
59
47
  try {
60
- cleanup = pending
61
- ? nil()
62
- : errors.length
63
- ? err(...errors)
64
- : ok(...values)
65
- } catch (e) {
66
- cleanup = err(toError(e))
67
- } finally {
68
- if (isFunction(cleanup)) run.off(cleanup)
48
+ if (isAsync) {
49
+ // Create AbortController for async callback
50
+ controller = new AbortController()
51
+ const currentController = controller
52
+ callback(controller.signal)
53
+ .then(cleanup => {
54
+ // Only register cleanup if this is still the current controller
55
+ if (
56
+ isFunction(cleanup) &&
57
+ controller === currentController
58
+ )
59
+ run.off(cleanup)
60
+ })
61
+ .catch(error => {
62
+ if (!isAbortError(error))
63
+ console.error('Async effect error:', error)
64
+ })
65
+ } else {
66
+ cleanup = (callback as () => MaybeCleanup)()
67
+ if (isFunction(cleanup)) run.off(cleanup)
68
+ }
69
+ } catch (error) {
70
+ if (!isAbortError(error))
71
+ console.error('Effect callback error:', error)
69
72
  }
70
73
 
71
74
  running = false
72
75
  }, run),
73
76
  )
77
+
74
78
  run()
75
- return () => run.cleanup()
79
+ return () => {
80
+ controller?.abort()
81
+ run.cleanup()
82
+ }
76
83
  }
77
84
 
78
85
  /* === Exports === */
79
86
 
80
- export { type EffectMatcher, effect }
87
+ export { type MaybeCleanup, type EffectCallback, effect }
package/src/match.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { ResolveResult } from './resolve'
2
+ import type { SignalValues, UnknownSignalRecord } from './signal'
3
+ import { toError } from './util'
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 handlers.ok?.(result.values as SignalValues<S>)
35
+ } catch (error) {
36
+ // If handler throws, try error handler, otherwise rethrow
37
+ if (
38
+ handlers.err &&
39
+ (!result.errors || !result.errors.includes(toError(error)))
40
+ )
41
+ handlers.err(
42
+ result.errors
43
+ ? [...result.errors, toError(error)]
44
+ : [toError(error)],
45
+ )
46
+ else throw error
47
+ }
48
+ }
49
+
50
+ /* === Exports === */
51
+
52
+ export { match, type MatchHandlers }
package/src/resolve.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type { UnknownRecord } from './diff'
2
+ import { type SignalValues, UNSET, type UnknownSignalRecord } from './signal'
3
+ import { toError } from './util'
4
+
5
+ /* === Types === */
6
+
7
+ type ResolveResult<S extends UnknownSignalRecord> =
8
+ | { ok: true; values: SignalValues<S>; errors?: never; pending?: never }
9
+ | { ok: false; errors: readonly Error[]; values?: never; pending?: never }
10
+ | { ok: false; pending: true; values?: never; errors?: never }
11
+
12
+ /* === Functions === */
13
+
14
+ /**
15
+ * Resolve signal values with perfect type inference
16
+ *
17
+ * Always returns a discriminated union result, regardless of whether
18
+ * handlers are provided or not. This ensures a predictable API.
19
+ *
20
+ * @since 0.15.0
21
+ * @param {S} signals - Signals to resolve
22
+ * @returns {ResolveResult<S>} - Discriminated union result
23
+ */
24
+ function resolve<S extends UnknownSignalRecord>(signals: S): ResolveResult<S> {
25
+ const errors: Error[] = []
26
+ let pending = false
27
+ const values: UnknownRecord = {}
28
+
29
+ // Collect values and errors
30
+ for (const [key, signal] of Object.entries(signals)) {
31
+ try {
32
+ const value = signal.get()
33
+ if (value === UNSET) pending = true
34
+ else values[key] = value
35
+ } catch (e) {
36
+ errors.push(toError(e))
37
+ }
38
+ }
39
+
40
+ // Return discriminated union
41
+ if (pending) return { ok: false, pending: true }
42
+ if (errors.length > 0) return { ok: false, errors }
43
+ return { ok: true, values: values as SignalValues<S> }
44
+ }
45
+
46
+ /* === Exports === */
47
+
48
+ export { resolve, type ResolveResult }
package/src/signal.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  import {
2
+ type Computed,
2
3
  type ComputedCallback,
3
4
  computed,
4
5
  isComputed,
5
6
  isComputedCallback,
6
7
  } from './computed'
7
- import { isState, state } from './state'
8
+ import { isState, type State, state } from './state'
9
+ import { isStore, type Store, store } from './store'
10
+ import { isRecord } from './util'
8
11
 
9
12
  /* === Types === */
10
13
 
@@ -13,7 +16,9 @@ type Signal<T extends {}> = {
13
16
  }
14
17
  type MaybeSignal<T extends {}> = T | Signal<T> | ComputedCallback<T>
15
18
 
16
- type SignalValues<S extends Signal<unknown & {}>[]> = {
19
+ type UnknownSignalRecord = Record<string, Signal<unknown & {}>>
20
+
21
+ type SignalValues<S extends UnknownSignalRecord> = {
17
22
  [K in keyof S]: S[K] extends Signal<infer T> ? T : never
18
23
  }
19
24
 
@@ -33,32 +38,73 @@ const UNSET: any = Symbol()
33
38
  */
34
39
  const isSignal = /*#__PURE__*/ <T extends {}>(
35
40
  value: unknown,
36
- ): value is Signal<T> => isState(value) || isComputed(value)
41
+ ): value is Signal<T> => isState(value) || isComputed(value) || isStore(value)
37
42
 
38
43
  /**
39
44
  * Convert a value to a Signal if it's not already a Signal
40
45
  *
41
46
  * @since 0.9.6
42
- * @param {MaybeSignal<T>} value - value to convert to a Signal
43
- * @returns {Signal<T>} - converted Signal
47
+ * @param {T} value - value to convert
48
+ * @returns {Signal<T>} - Signal instance
49
+ */
50
+ function toSignal<T extends {}>(value: T[]): Store<Record<string, T>>
51
+ function toSignal<T extends {}>(
52
+ value: (() => T) | ((abort: AbortSignal) => Promise<T>),
53
+ ): Computed<T>
54
+ function toSignal<T extends {}>(
55
+ value: T,
56
+ ): T extends Store<infer U>
57
+ ? Store<U>
58
+ : T extends State<infer U>
59
+ ? State<U>
60
+ : T extends Computed<infer U>
61
+ ? Computed<U>
62
+ : T extends Signal<infer U>
63
+ ? Signal<U>
64
+ : T extends Record<string, unknown & {}>
65
+ ? Store<{ [K in keyof T]: T[K] }>
66
+ : State<T>
67
+ function toSignal<T extends {}>(value: MaybeSignal<T>): Signal<T> {
68
+ if (isSignal<T>(value)) return value
69
+ if (isComputedCallback(value)) return computed(value)
70
+ if (Array.isArray(value)) return store(value as T)
71
+ if (Array.isArray(value) || isRecord(value)) return store(value)
72
+ return state(value)
73
+ }
74
+
75
+ /**
76
+ * Convert a value to a mutable Signal if it's not already a Signal
77
+ *
78
+ * @since 0.15.0
79
+ * @param {T} value - value to convert
80
+ * @returns {State<T> | Store<T>} - Signal instance
44
81
  */
45
- const toSignal = /*#__PURE__*/ <T extends {}>(
46
- value: MaybeSignal<T>,
47
- ): Signal<T> =>
48
- isSignal<T>(value)
49
- ? value
50
- : isComputedCallback<T>(value)
51
- ? computed(value)
52
- : state(value as T)
82
+ function toMutableSignal<T extends {}>(value: T[]): Store<Record<string, T>>
83
+ function toMutableSignal<T extends {}>(
84
+ value: T,
85
+ ): T extends Store<infer U>
86
+ ? Store<U>
87
+ : T extends State<infer U>
88
+ ? State<U>
89
+ : T extends Record<string, unknown & {}>
90
+ ? Store<{ [K in keyof T]: T[K] }>
91
+ : State<T>
92
+ function toMutableSignal<T extends {}>(value: T): State<T> | Store<T> {
93
+ if (isState<T>(value) || isStore<T>(value)) return value
94
+ if (Array.isArray(value)) return store(value as T)
95
+ if (isRecord(value)) return store(value)
96
+ return state(value)
97
+ }
53
98
 
54
99
  /* === Exports === */
55
100
 
56
101
  export {
57
102
  type Signal,
58
103
  type MaybeSignal,
104
+ type UnknownSignalRecord,
59
105
  type SignalValues,
60
106
  UNSET,
61
107
  isSignal,
62
- isComputedCallback,
63
108
  toSignal,
109
+ toMutableSignal,
64
110
  }
package/src/state.ts CHANGED
@@ -1,6 +1,7 @@
1
+ import { isEqual } from './diff'
2
+ import { notify, subscribe, type Watcher } from './scheduler'
1
3
  import { UNSET } from './signal'
2
4
  import { isObjectOfType } from './util'
3
- import { type Watcher, notify, subscribe } from './scheduler'
4
5
 
5
6
  /* === Types === */
6
7
 
@@ -50,7 +51,7 @@ const state = /*#__PURE__*/ <T extends {}>(initialValue: T): State<T> => {
50
51
  * @returns {void}
51
52
  */
52
53
  set: (v: T): void => {
53
- if (Object.is(value, v)) return
54
+ if (isEqual(value, v)) return
54
55
  value = v
55
56
  notify(watchers)
56
57
 
@@ -86,4 +87,4 @@ const isState = /*#__PURE__*/ <T extends {}>(
86
87
 
87
88
  /* === Exports === */
88
89
 
89
- export { type State, TYPE_STATE, state, isState }
90
+ export { TYPE_STATE, isState, state, type State }