@zeix/cause-effect 0.13.1 → 0.14.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/signal.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { isState, state } from './state'
2
+ import {
3
+ type ComputedCallback,
4
+ isComputed,
5
+ computed,
6
+ } from './computed'
7
+ import { isFunction } from './util'
8
+
9
+ /* === Types === */
10
+
11
+ type Signal<T extends {}> = {
12
+ get(): T
13
+ }
14
+ type MaybeSignal<T extends {}> = T | Signal<T> | ComputedCallback<T>
15
+
16
+ /* === Constants === */
17
+
18
+ const UNSET: any = Symbol()
19
+
20
+ /* === Exported Functions === */
21
+
22
+ /**
23
+ * Check whether a value is a Signal or not
24
+ *
25
+ * @since 0.9.0
26
+ * @param {unknown} value - value to check
27
+ * @returns {boolean} - true if value is a Signal, false otherwise
28
+ */
29
+ const isSignal = /*#__PURE__*/ <T extends {}>(
30
+ value: unknown,
31
+ ): value is Signal<T> => isState(value) || isComputed(value)
32
+
33
+ /**
34
+ * Check if the provided value is a callback that may be used as input for toSignal() to derive a computed state
35
+ *
36
+ * @since 0.12.0
37
+ * @param {unknown} value - value to check
38
+ * @returns {boolean} - true if value is a callback or callbacks object, false otherwise
39
+ */
40
+ const isComputedCallback = /*#__PURE__*/ <T extends {}>(
41
+ value: unknown,
42
+ ): value is ComputedCallback<T> => isFunction(value) && value.length < 2
43
+
44
+ /**
45
+ * Convert a value to a Signal if it's not already a Signal
46
+ *
47
+ * @since 0.9.6
48
+ * @param {MaybeSignal<T>} value - value to convert to a Signal
49
+ * @returns {Signal<T>} - converted Signal
50
+ */
51
+ const toSignal = /*#__PURE__*/ <T extends {}>(
52
+ value: MaybeSignal<T>,
53
+ ): Signal<T> =>
54
+ isSignal<T>(value)
55
+ ? value
56
+ : isComputedCallback<T>(value)
57
+ ? computed(value)
58
+ : state(value as T)
59
+
60
+ /* === Exports === */
61
+
62
+ export {
63
+ type Signal,
64
+ type MaybeSignal,
65
+ UNSET,
66
+ isSignal,
67
+ isComputedCallback,
68
+ toSignal,
69
+ }
@@ -1,12 +1,8 @@
1
- import { type Computed } from './computed';
2
- import { type TapMatcher } from './effect';
3
- export type State<T extends {}> = {
1
+ type State<T extends {}> = {
4
2
  [Symbol.toStringTag]: 'State';
5
3
  get(): T;
6
4
  set(v: T): void;
7
5
  update(fn: (v: T) => T): void;
8
- map<U extends {}>(fn: (v: T) => U | Promise<U>): Computed<U>;
9
- tap(matcher: TapMatcher<T> | ((v: T) => void | (() => void))): () => void;
10
6
  };
11
7
  /**
12
8
  * Create a new state signal
@@ -15,7 +11,7 @@ export type State<T extends {}> = {
15
11
  * @param {T} initialValue - initial value of the state
16
12
  * @returns {State<T>} - new state signal
17
13
  */
18
- export declare const state: <T extends {}>(initialValue: T) => State<T>;
14
+ declare const state: <T extends {}>(initialValue: T) => State<T>;
19
15
  /**
20
16
  * Check if the provided value is a State instance
21
17
  *
@@ -23,4 +19,5 @@ export declare const state: <T extends {}>(initialValue: T) => State<T>;
23
19
  * @param {unknown} value - value to check
24
20
  * @returns {boolean} - true if the value is a State instance, false otherwise
25
21
  */
26
- export declare const isState: <T extends {}>(value: unknown) => value is State<T>;
22
+ declare const isState: <T extends {}>(value: unknown) => value is State<T>;
23
+ export { type State, state, isState };
package/src/state.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { UNSET } from './signal'
2
+ import { isObjectOfType } from './util'
3
+ import { type Watcher, notify, subscribe } from './scheduler'
4
+
5
+ /* === Types === */
6
+
7
+ type State<T extends {}> = {
8
+ [Symbol.toStringTag]: 'State'
9
+ get(): T
10
+ set(v: T): void
11
+ update(fn: (v: T) => T): void
12
+ }
13
+
14
+ /* === Constants === */
15
+
16
+ const TYPE_STATE = 'State'
17
+
18
+ /* === Functions === */
19
+
20
+ /**
21
+ * Create a new state signal
22
+ *
23
+ * @since 0.9.0
24
+ * @param {T} initialValue - initial value of the state
25
+ * @returns {State<T>} - new state signal
26
+ */
27
+ const state = /*#__PURE__*/ <T extends {}>(initialValue: T): State<T> => {
28
+ const watchers: Set<Watcher> = new Set()
29
+ let value: T = initialValue
30
+
31
+ const s: State<T> = {
32
+ [Symbol.toStringTag]: TYPE_STATE,
33
+
34
+ /**
35
+ * Get the current value of the state
36
+ *
37
+ * @since 0.9.0
38
+ * @returns {T} - current value of the state
39
+ */
40
+ get: (): T => {
41
+ subscribe(watchers)
42
+ return value
43
+ },
44
+
45
+ /**
46
+ * Set a new value of the state
47
+ *
48
+ * @since 0.9.0
49
+ * @param {T} v
50
+ * @returns {void}
51
+ */
52
+ set: (v: T): void => {
53
+ if (Object.is(value, v)) return
54
+ value = v
55
+ notify(watchers)
56
+
57
+ // Setting to UNSET clears the watchers so the signal can be garbage collected
58
+ if (UNSET === value) watchers.clear()
59
+ },
60
+
61
+ /**
62
+ * Update the state with a new value using a function
63
+ *
64
+ * @since 0.10.0
65
+ * @param {(v: T) => T} fn - function to update the state
66
+ * @returns {void} - updates the state with the result of the function
67
+ */
68
+ update: (fn: (v: T) => T): void => {
69
+ s.set(fn(value))
70
+ },
71
+ }
72
+
73
+ return s
74
+ }
75
+
76
+ /**
77
+ * Check if the provided value is a State instance
78
+ *
79
+ * @since 0.9.0
80
+ * @param {unknown} value - value to check
81
+ * @returns {boolean} - true if the value is a State instance, false otherwise
82
+ */
83
+ const isState = /*#__PURE__*/ <T extends {}>(
84
+ value: unknown,
85
+ ): value is State<T> => isObjectOfType(value, TYPE_STATE)
86
+
87
+ /* === Exports === */
88
+
89
+ export { type State, state, isState }
package/src/task.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { type Computed } from './computed';
2
+ /**
3
+ * Callback for async computation tasks
4
+ * This explicitly returns a Promise<T> to differentiate from MemoCallback
5
+ *
6
+ * @since 0.14.0
7
+ */
8
+ type TaskCallback<T extends {}> = (abort: AbortSignal) => Promise<T>;
9
+ /**
10
+ * Create a derived signal that supports asynchronous computations
11
+ *
12
+ * @since 0.14.0
13
+ * @param {TaskCallback<T>} fn - async computation callback
14
+ * @returns {Computed<T>} - Computed signal
15
+ */
16
+ declare const task: <T extends {}>(fn: TaskCallback<T>) => Computed<T>;
17
+ export { type TaskCallback, task };
package/src/task.ts ADDED
@@ -0,0 +1,153 @@
1
+ import { UNSET } from './signal'
2
+ import {
3
+ CircularDependencyError,
4
+ isAbortError,
5
+ isPromise,
6
+ toError,
7
+ } from './util'
8
+ import {
9
+ type Cleanup,
10
+ type Watcher,
11
+ flush,
12
+ notify,
13
+ subscribe,
14
+ watch,
15
+ } from './scheduler'
16
+ import {
17
+ type Computed,
18
+ TYPE_COMPUTED,
19
+ } from './computed'
20
+
21
+ /* === Types === */
22
+
23
+ /**
24
+ * Callback for async computation tasks
25
+ * This explicitly returns a Promise<T> to differentiate from MemoCallback
26
+ *
27
+ * @since 0.14.0
28
+ */
29
+ type TaskCallback<T extends {}> = (abort: AbortSignal) => Promise<T>
30
+
31
+ /* === Function === */
32
+
33
+ /**
34
+ * Create a derived signal that supports asynchronous computations
35
+ *
36
+ * @since 0.14.0
37
+ * @param {TaskCallback<T>} fn - async computation callback
38
+ * @returns {Computed<T>} - Computed signal
39
+ */
40
+ const task = <T extends {}>(fn: TaskCallback<T>): Computed<T> => {
41
+ const watchers: Set<Watcher> = new Set()
42
+
43
+ // Internal state
44
+ let value: T = UNSET
45
+ let error: Error | undefined
46
+ let dirty = true
47
+ let changed = false
48
+ let computing = false
49
+ let controller: AbortController | undefined
50
+
51
+ // Functions to update internal state
52
+ const ok = (v: T) => {
53
+ if (!Object.is(v, value)) {
54
+ value = v
55
+ dirty = false
56
+ error = undefined
57
+ changed = true
58
+ }
59
+ }
60
+ const nil = () => {
61
+ changed = UNSET !== value
62
+ value = UNSET
63
+ error = undefined
64
+ }
65
+ const err = (e: unknown) => {
66
+ const newError = toError(e)
67
+ changed = !(
68
+ error &&
69
+ newError.name === error.name &&
70
+ newError.message === error.message
71
+ )
72
+ value = UNSET
73
+ error = newError
74
+ }
75
+ const resolve = (v: T) => {
76
+ computing = false
77
+ controller = undefined
78
+ ok(v)
79
+ if (changed) notify(watchers)
80
+ }
81
+ const reject = (e: unknown) => {
82
+ computing = false
83
+ controller = undefined
84
+ err(e)
85
+ if (changed) notify(watchers)
86
+ }
87
+ const abort = () => {
88
+ computing = false
89
+ controller = undefined
90
+ compute() // retry
91
+ }
92
+
93
+ // Called when notified from sources (push)
94
+ const mark = (() => {
95
+ dirty = true
96
+ controller?.abort('Aborted because source signal changed')
97
+ if (watchers.size) {
98
+ notify(watchers)
99
+ } else {
100
+ mark.cleanups.forEach(fn => fn())
101
+ mark.cleanups.clear()
102
+ }
103
+ }) as Watcher
104
+ mark.cleanups = new Set<Cleanup>()
105
+
106
+ // Called when requested by dependencies (pull)
107
+ const compute = () =>
108
+ watch(() => {
109
+ if (computing) throw new CircularDependencyError('task')
110
+ changed = false
111
+ controller = new AbortController()
112
+ controller.signal.addEventListener('abort', abort, {
113
+ once: true,
114
+ })
115
+
116
+ let result: T | Promise<T>
117
+ computing = true
118
+ try {
119
+ result = fn(controller.signal)
120
+ } catch (e) {
121
+ if (isAbortError(e)) nil()
122
+ else err(e)
123
+ computing = false
124
+ return
125
+ }
126
+ if (isPromise(result)) result.then(resolve, reject)
127
+ else if (null == result || UNSET === result) nil()
128
+ else ok(result)
129
+ computing = false
130
+ }, mark)
131
+
132
+ const c: Computed<T> = {
133
+ [Symbol.toStringTag]: TYPE_COMPUTED,
134
+
135
+ /**
136
+ * Get the current value of the computed
137
+ *
138
+ * @returns {T} - current value of the computed
139
+ */
140
+ get: (): T => {
141
+ subscribe(watchers)
142
+ flush()
143
+ if (dirty) compute()
144
+ if (error) throw error
145
+ return value
146
+ },
147
+ }
148
+ return c
149
+ }
150
+
151
+ /* === Exports === */
152
+
153
+ export { type TaskCallback, task }
@@ -8,4 +8,4 @@ declare const toError: (reason: unknown) => Error;
8
8
  declare class CircularDependencyError extends Error {
9
9
  constructor(where: string);
10
10
  }
11
- export { isFunction, isAsyncFunction, isObjectOfType, isError, isAbortError, isPromise, toError, CircularDependencyError };
11
+ export { isFunction, isAsyncFunction, isObjectOfType, isError, isAbortError, isPromise, toError, CircularDependencyError, };
@@ -1,13 +1,18 @@
1
1
  /* === Utility Functions === */
2
2
 
3
- const isFunction = /*#__PURE__*/ <T>(value: unknown): value is (...args: unknown[]) => T =>
4
- typeof value === 'function'
3
+ const isFunction = /*#__PURE__*/ <T>(
4
+ value: unknown,
5
+ ): value is (...args: unknown[]) => T => typeof value === 'function'
5
6
 
6
- const isAsyncFunction = /*#__PURE__*/ <T>(value: unknown): value is (...args: unknown[]) => Promise<T> =>
7
+ const isAsyncFunction = /*#__PURE__*/ <T>(
8
+ value: unknown,
9
+ ): value is (...args: unknown[]) => Promise<T> =>
7
10
  isFunction(value) && value.constructor.name === 'AsyncFunction'
8
11
 
9
- const isObjectOfType = /*#__PURE__*/ <T>(value: unknown, type: string): value is T =>
10
- Object.prototype.toString.call(value) === `[object ${type}]`
12
+ const isObjectOfType = /*#__PURE__*/ <T>(
13
+ value: unknown,
14
+ type: string,
15
+ ): value is T => Object.prototype.toString.call(value) === `[object ${type}]`
11
16
 
12
17
  const isError = /*#__PURE__*/ (value: unknown): value is Error =>
13
18
  value instanceof Error
@@ -20,13 +25,20 @@ const toError = (reason: unknown): Error =>
20
25
 
21
26
  class CircularDependencyError extends Error {
22
27
  constructor(where: string) {
23
- super(`Circular dependency in ${where} detected`)
28
+ super(`Circular dependency in ${where} detected`)
24
29
  return this
25
- }
30
+ }
26
31
  }
27
32
 
33
+ /* === Exports === */
34
+
28
35
  export {
29
- isFunction, isAsyncFunction,
30
- isObjectOfType, isError, isAbortError, isPromise, toError,
31
- CircularDependencyError
32
- }
36
+ isFunction,
37
+ isAsyncFunction,
38
+ isObjectOfType,
39
+ isError,
40
+ isAbortError,
41
+ isPromise,
42
+ toError,
43
+ CircularDependencyError,
44
+ }
@@ -1,20 +1,15 @@
1
1
  import { describe, test, expect } from 'bun:test'
2
- import { state, computed, effect, batch } from '../'
3
-
4
- /* === Utility Functions === */
5
-
6
- const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
2
+ import { state, memo, batch, effect } from '../'
7
3
 
8
4
  /* === Tests === */
9
5
 
10
6
  describe('Batch', function () {
11
-
12
- test('should be triggered only once after repeated state change', function() {
7
+ test('should be triggered only once after repeated state change', function () {
13
8
  const cause = state(0)
14
9
  let result = 0
15
10
  let count = 0
16
- cause.tap(res => {
17
- result = res
11
+ effect(() => {
12
+ result = cause.get()
18
13
  count++
19
14
  })
20
15
  batch(() => {
@@ -23,17 +18,18 @@ describe('Batch', function () {
23
18
  }
24
19
  })
25
20
  expect(result).toBe(10)
26
- expect(count).toBe(2); // + 1 for effect initialization
21
+ expect(count).toBe(2) // + 1 for effect initialization
27
22
  })
28
23
 
29
- test('should be triggered only once when multiple signals are set', function() {
24
+ test('should be triggered only once when multiple signals are set', function () {
30
25
  const a = state(3)
31
26
  const b = state(4)
32
27
  const c = state(5)
33
- const sum = computed(() => a.get() + b.get() + c.get())
28
+ const sum = memo(() => a.get() + b.get() + c.get())
34
29
  let result = 0
35
30
  let count = 0
36
- sum.tap({
31
+ effect({
32
+ signals: [sum],
37
33
  ok: res => {
38
34
  result = res
39
35
  count++
@@ -46,27 +42,27 @@ describe('Batch', function () {
46
42
  c.set(10)
47
43
  })
48
44
  expect(result).toBe(24)
49
- expect(count).toBe(2); // + 1 for effect initialization
45
+ expect(count).toBe(2) // + 1 for effect initialization
50
46
  })
51
47
 
52
- test('should prove example from README works', function() {
53
-
48
+ test('should prove example from README works', function () {
54
49
  // State: define an array of Signal<number>
55
50
  const signals = [state(2), state(3), state(5)]
56
51
 
57
52
  // Computed: derive a calculation ...
58
- const sum = computed(() => signals.reduce((total, v) => total + v.get(), 0))
59
- .map(v => { // ... perform validation and handle errors
60
- if (!Number.isFinite(v)) throw new Error('Invalid value')
61
- return v
62
- })
53
+ const sum = memo(() => {
54
+ const v = signals.reduce((total, v) => total + v.get(), 0)
55
+ if (!Number.isFinite(v)) throw new Error('Invalid value')
56
+ return v
57
+ })
63
58
 
64
59
  let result = 0
65
60
  let okCount = 0
66
61
  let errCount = 0
67
-
62
+
68
63
  // Effect: switch cases for the result
69
- sum.tap({
64
+ effect({
65
+ signals: [sum],
70
66
  ok: v => {
71
67
  result = v
72
68
  okCount++
@@ -75,7 +71,7 @@ describe('Batch', function () {
75
71
  err: _error => {
76
72
  errCount++
77
73
  // console.error('Error:', error)
78
- }
74
+ },
79
75
  })
80
76
 
81
77
  expect(okCount).toBe(1)
@@ -92,9 +88,8 @@ describe('Batch', function () {
92
88
  // Provoke an error
93
89
  signals[0].set(NaN)
94
90
 
95
- expect(errCount).toBe(1)
96
- expect(okCount).toBe(2); // should not have changed due to error
97
- expect(result).toBe(20); // should not have changed due to error
91
+ expect(errCount).toBe(1)
92
+ expect(okCount).toBe(2) // should not have changed due to error
93
+ expect(result).toBe(20) // should not have changed due to error
98
94
  })
99
-
100
95
  })