@zeix/cause-effect 0.14.2 → 0.15.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.
package/src/diff.ts ADDED
@@ -0,0 +1,136 @@
1
+ import { UNSET } from './signal'
2
+ import { CircularDependencyError, isRecord } from './util'
3
+
4
+ /* === Types === */
5
+
6
+ type UnknownRecord = Record<string, unknown & {}>
7
+
8
+ type DiffResult<T extends UnknownRecord = UnknownRecord> = {
9
+ changed: boolean
10
+ add: Partial<T>
11
+ change: Partial<T>
12
+ remove: Partial<T>
13
+ }
14
+
15
+ /* === Functions === */
16
+
17
+ /**
18
+ * Checks if two values are equal with cycle detection
19
+ *
20
+ * @since 0.15.0
21
+ * @param {T} a - First value to compare
22
+ * @param {T} b - Second value to compare
23
+ * @param {WeakSet<object>} visited - Set to track visited objects for cycle detection
24
+ * @returns {boolean} Whether the two values are equal
25
+ */
26
+ const isEqual = <T>(a: T, b: T, visited?: WeakSet<object>): boolean => {
27
+ // Fast paths
28
+ if (Object.is(a, b)) return true
29
+ if (typeof a !== typeof b) return false
30
+ if (typeof a !== 'object' || a === null || b === null) return false
31
+
32
+ // Cycle detection
33
+ if (!visited) visited = new WeakSet()
34
+ if (visited.has(a as object) || visited.has(b as object))
35
+ throw new CircularDependencyError('isEqual')
36
+ visited.add(a as object)
37
+ visited.add(b as object)
38
+
39
+ try {
40
+ if (Array.isArray(a) && Array.isArray(b)) {
41
+ if (a.length !== b.length) return false
42
+ for (let i = 0; i < a.length; i++) {
43
+ if (!isEqual(a[i], b[i], visited)) return false
44
+ }
45
+ return true
46
+ }
47
+
48
+ if (Array.isArray(a) !== Array.isArray(b)) return false
49
+
50
+ if (isRecord(a) && isRecord(b)) {
51
+ const aKeys = Object.keys(a)
52
+ const bKeys = Object.keys(b)
53
+
54
+ if (aKeys.length !== bKeys.length) return false
55
+ for (const key of aKeys) {
56
+ if (!(key in b)) return false
57
+ if (
58
+ !isEqual(
59
+ (a as Record<string, unknown>)[key],
60
+ (b as Record<string, unknown>)[key],
61
+ visited,
62
+ )
63
+ )
64
+ return false
65
+ }
66
+ return true
67
+ }
68
+
69
+ return false
70
+ } finally {
71
+ visited.delete(a as object)
72
+ visited.delete(b as object)
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Compares two records and returns a result object containing the differences.
78
+ *
79
+ * @since 0.15.0
80
+ * @param {T} oldObj - The old record to compare
81
+ * @param {T} newObj - The new record to compare
82
+ * @returns {DiffResult<T>} The result of the comparison
83
+ */
84
+ const diff = <T extends UnknownRecord>(oldObj: T, newObj: T): DiffResult<T> => {
85
+ const visited = new WeakSet<object>()
86
+
87
+ const diffRecords = (
88
+ oldRecord: Record<string, unknown>,
89
+ newRecord: Record<string, unknown>,
90
+ ): DiffResult<T> => {
91
+ const add: Partial<T> = {}
92
+ const change: Partial<T> = {}
93
+ const remove: Partial<T> = {}
94
+
95
+ const oldKeys = Object.keys(oldRecord)
96
+ const newKeys = Object.keys(newRecord)
97
+ const allKeys = new Set([...oldKeys, ...newKeys])
98
+
99
+ for (const key of allKeys) {
100
+ const oldHas = key in oldRecord
101
+ const newHas = key in newRecord
102
+
103
+ if (!oldHas && newHas) {
104
+ add[key as keyof T] = newRecord[key] as T[keyof T]
105
+ continue
106
+ } else if (oldHas && !newHas) {
107
+ remove[key as keyof T] = UNSET
108
+ continue
109
+ }
110
+
111
+ const oldValue = oldRecord[key] as T[keyof T]
112
+ const newValue = newRecord[key] as T[keyof T]
113
+
114
+ if (!isEqual(oldValue, newValue, visited))
115
+ change[key as keyof T] = newValue
116
+ }
117
+
118
+ const changed =
119
+ Object.keys(add).length > 0 ||
120
+ Object.keys(change).length > 0 ||
121
+ Object.keys(remove).length > 0
122
+
123
+ return {
124
+ changed,
125
+ add,
126
+ change,
127
+ remove,
128
+ }
129
+ }
130
+
131
+ return diffRecords(oldObj, newObj)
132
+ }
133
+
134
+ /* === Exports === */
135
+
136
+ export { type DiffResult, diff, isEqual, type UnknownRecord }
package/src/effect.ts CHANGED
@@ -1,80 +1,88 @@
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
+ })
62
+ .catch(error => {
63
+ if (!isAbortError(error))
64
+ console.error('Async effect error:', error)
65
+ })
66
+ } else {
67
+ cleanup = (callback as () => MaybeCleanup)()
68
+ if (isFunction(cleanup)) run.off(cleanup)
69
+ }
70
+ } catch (error) {
71
+ if (!isAbortError(error))
72
+ console.error('Effect callback error:', error)
69
73
  }
70
74
 
71
75
  running = false
72
76
  }, run),
73
77
  )
78
+
74
79
  run()
75
- return () => run.cleanup()
80
+ return () => {
81
+ controller?.abort()
82
+ run.cleanup()
83
+ }
76
84
  }
77
85
 
78
86
  /* === Exports === */
79
87
 
80
- export { type EffectMatcher, effect }
88
+ export { type MaybeCleanup, type EffectCallback, effect }
package/src/match.ts ADDED
@@ -0,0 +1,57 @@
1
+ import type { ResolveResult } from './resolve'
2
+ import type { Signal, SignalValues } from './signal'
3
+ import { toError } from './util'
4
+
5
+ /* === Types === */
6
+
7
+ type MatchHandlers<S extends Record<string, Signal<unknown & {}>>> = {
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 Record<string, Signal<unknown & {}>>>(
28
+ result: ResolveResult<S>,
29
+ handlers: MatchHandlers<S>,
30
+ ): void {
31
+ try {
32
+ if (result.pending) {
33
+ handlers.nil?.()
34
+ } else if (result.errors) {
35
+ handlers.err?.(result.errors)
36
+ } else {
37
+ handlers.ok?.(result.values as SignalValues<S>)
38
+ }
39
+ } catch (error) {
40
+ // If handler throws, try error handler, otherwise rethrow
41
+ if (
42
+ handlers.err &&
43
+ (!result.errors || !result.errors.includes(toError(error)))
44
+ ) {
45
+ const allErrors = result.errors
46
+ ? [...result.errors, toError(error)]
47
+ : [toError(error)]
48
+ handlers.err(allErrors)
49
+ } else {
50
+ throw error
51
+ }
52
+ }
53
+ }
54
+
55
+ /* === Exports === */
56
+
57
+ export { match, type MatchHandlers }
package/src/resolve.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { UnknownRecord } from './diff'
2
+ import { type Signal, type SignalValues, UNSET } from './signal'
3
+ import { toError } from './util'
4
+
5
+ /* === Types === */
6
+
7
+ type ResolveResult<S extends Record<string, Signal<unknown & {}>>> =
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 Record<string, Signal<unknown & {}>>>(
25
+ signals: S,
26
+ ): ResolveResult<S> {
27
+ const errors: Error[] = []
28
+ let pending = false
29
+ const values: UnknownRecord = {}
30
+
31
+ // Collect values and errors
32
+ for (const [key, signal] of Object.entries(signals)) {
33
+ try {
34
+ const value = signal.get()
35
+
36
+ if (value === UNSET) {
37
+ pending = true
38
+ } else {
39
+ values[key] = value
40
+ }
41
+ } catch (e) {
42
+ errors.push(toError(e))
43
+ }
44
+ }
45
+
46
+ // Return discriminated union
47
+ if (pending) {
48
+ return { ok: false, pending: true }
49
+ }
50
+ if (errors.length > 0) {
51
+ return { ok: false, errors }
52
+ }
53
+ return { ok: true, values: values as SignalValues<S> }
54
+ }
55
+
56
+ /* === Exports === */
57
+
58
+ 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 { arrayToRecord, isRecord } from './util'
8
11
 
9
12
  /* === Types === */
10
13
 
@@ -13,7 +16,7 @@ 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 SignalValues<S extends Record<string, Signal<unknown & {}>>> = {
17
20
  [K in keyof S]: S[K] extends Signal<infer T> ? T : never
18
21
  }
19
22
 
@@ -33,23 +36,52 @@ const UNSET: any = Symbol()
33
36
  */
34
37
  const isSignal = /*#__PURE__*/ <T extends {}>(
35
38
  value: unknown,
36
- ): value is Signal<T> => isState(value) || isComputed(value)
39
+ ): value is Signal<T> => isState(value) || isComputed(value) || isStore(value)
37
40
 
38
41
  /**
39
42
  * Convert a value to a Signal if it's not already a Signal
40
43
  *
41
44
  * @since 0.9.6
42
- * @param {MaybeSignal<T>} value - value to convert to a Signal
43
- * @returns {Signal<T>} - converted Signal
44
45
  */
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)
46
+ function toSignal<T extends Array<unknown & {}>>(
47
+ value: T[],
48
+ ): Store<Record<string, T>>
49
+ function toSignal<T extends Record<keyof T, T[keyof T]>>(value: T): Store<T>
50
+ function toSignal<T extends {}>(value: ComputedCallback<T>): Computed<T>
51
+ function toSignal<T extends {}>(value: Signal<T>): Signal<T>
52
+ function toSignal<T extends {}>(value: T): State<T>
53
+ function toSignal<T extends {}>(
54
+ value: MaybeSignal<T> | T[],
55
+ ): Signal<T> | Store<Record<string, T>> {
56
+ if (isSignal<T>(value)) return value
57
+ if (isComputedCallback<T>(value)) return computed(value)
58
+ if (Array.isArray(value)) return store(arrayToRecord(value))
59
+ if (isRecord(value)) return store(value as T)
60
+ return state(value as T)
61
+ }
62
+
63
+ /**
64
+ * Convert a value to a mutable Signal if it's not already a Signal
65
+ *
66
+ * @since 0.9.6
67
+ */
68
+ function toMutableSignal<T extends Array<unknown & {}>>(
69
+ value: T[],
70
+ ): Store<Record<string, T>>
71
+ function toMutableSignal<T extends Record<keyof T, T[keyof T]>>(
72
+ value: T,
73
+ ): Store<T>
74
+ function toMutableSignal<T extends State<T>>(value: State<T>): State<T>
75
+ function toMutableSignal<T extends Store<T>>(value: Store<T>): Store<T>
76
+ function toMutableSignal<T extends {}>(value: T): State<T>
77
+ function toMutableSignal<T extends {}>(
78
+ value: T | State<T> | Store<T> | T[],
79
+ ): Signal<T> | Store<Record<string, T>> {
80
+ if (isState<T>(value) || isStore<T>(value)) return value
81
+ if (Array.isArray(value)) return store(arrayToRecord(value))
82
+ if (isRecord(value)) return store(value as T)
83
+ return state(value as T)
84
+ }
53
85
 
54
86
  /* === Exports === */
55
87
 
@@ -59,6 +91,6 @@ export {
59
91
  type SignalValues,
60
92
  UNSET,
61
93
  isSignal,
62
- isComputedCallback,
63
94
  toSignal,
95
+ toMutableSignal,
64
96
  }
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 }