@zeix/cause-effect 0.10.0 → 0.11.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/README.md CHANGED
@@ -1,14 +1,48 @@
1
1
  # Cause & Effect
2
2
 
3
- Version 0.10.0
3
+ Version 0.11.0
4
4
 
5
- **Cause & Effect** - efficient state management with signals that sync instantly and reactively across your application.
5
+ **Cause & Effect** is a lightweight, reactive state management library for JavaScript applications. It uses the concept of signals to create a predictable and efficient data flow in your app.
6
+
7
+ ## What is Cause & Effect?
8
+
9
+ **Cause & Effect** provides a simple way to manage application state using signals. Signals are containers for values that can change over time. When a signal's value changes, it automatically updates all parts of your app that depend on it, ensuring your UI stays in sync with your data.
10
+
11
+ ## Why Cause & Effect?
12
+
13
+ - **Simplicity**: Easy to learn and use, with a small API surface.
14
+ - **Performance**: Efficient updates that only recompute what's necessary.
15
+ - **Type Safety**: Full TypeScript support for robust applications.
16
+ - **Flexibility**: Works well with any UI framework or vanilla JavaScript.
17
+ - **Lightweight**: Around 1kB gzipped over the wire.
6
18
 
7
19
  ## Key Features
8
20
 
9
- * **Efficient State Management**: Use lightweight signals for state updates that automatically notify dependents when needed.
10
- * **Support for Asynchronous Operations**: Handle state updates smoothly, even when dealing with network requests or Promise-based libraries, without disrupting reactivity.
11
- * **Memoized Computed Signals**: Optionally create derived values that are cached and automatically recalculated when source data changes.
21
+ - 🚀 Efficient state management with automatic dependency tracking
22
+ - Built-in support for async operations
23
+ - 🧠 Memoized computed values
24
+ - 🛡️ Type-safe and non-nullable signals
25
+ - 🎭 Declarative error and pending state handling
26
+
27
+ ## Quick Example
28
+
29
+ ```js
30
+ import { state, computed, effect } from '@zeix/cause-effect'
31
+
32
+ // Create a state signal
33
+ const count = state(0)
34
+
35
+ // Create a computed signal
36
+ const doubleCount = computed(() => count.get() * 2)
37
+
38
+ // Create an effect
39
+ effect(() => {
40
+ console.log(`Count: ${count.get()}, Double: ${doubleCount.get()}`)
41
+ })
42
+
43
+ // Update the state
44
+ count.set(5) // Logs: "Count: 5, Double: 10"
45
+ ```
12
46
 
13
47
  ## Installation
14
48
 
@@ -71,7 +105,7 @@ document.querySelector('button.increment')
71
105
 
72
106
  Async computed signals are as straight forward as their sync counterparts. Just create the computed signal with an async function.
73
107
 
74
- **Caution**: You can't use the `.map()` method to create an async computed signal. And async computed signals will return `undefined` until the Promise is resolved.
108
+ **Caution**: You can't use the `.map()` method to create an async computed signal. And async computed signals will return a Symbol `UNSET` until the Promise is resolved.
75
109
 
76
110
  ```js
77
111
  import { state, computed, effect } from '@zeix/cause-effect'
@@ -82,24 +116,44 @@ const entryData = computed(async () => {
82
116
  if (!response.ok) return new Error(`Failed to fetch data: ${response.statusText}`)
83
117
  return response.json()
84
118
  })
85
- effect(() => {
86
- let data
87
- try {
88
- data = entryData.get()
89
- } catch (error) {
90
- console.error(error.message) // logs the error message if an error ocurred
91
- return
92
- }
93
- if (null == data) return // doesn't do anything while we are still waiting for the data
94
- document.querySelector('.entry h2').textContent = data.title
95
- document.querySelector('.entry p').textContent = data.description
96
- })
97
119
  // Updates h1 and p of the entry as soon as fetched data for entry becomes available
98
120
  document.querySelector('button.next')
99
121
  .addEventListener('click', () => entryId.update(v => ++v))
100
122
  // Click on button updates h1 and p of the entry as soon as fetched data for the next entry is loaded
101
123
  ```
102
124
 
125
+ ### Handling Unset Values and Errors in Effects
126
+
127
+ Computations can fail and throw errors. Promises may not have resolved yet when you try to access their value. **Cause & Effect makes it easy to deal with errors and unresolved async functions.** Computed functions will catch errors and re-throw them when you access their values.
128
+
129
+ **Effects** are where you handle different cases:
130
+
131
+ ```js
132
+ const h2 = document.querySelector('.entry h2')
133
+ const p = document.querySelector('.entry p')
134
+ effect({
135
+
136
+ // Handle pending states while fetching data
137
+ nil: () => {
138
+ h2.textContent = 'Loading...'
139
+ },
140
+
141
+ // Handle errors
142
+ err: (error) => {
143
+ h2.textContent = 'Oops, Something Went Wrong'
144
+ p.textContent = error.message
145
+ },
146
+
147
+ // Happy path, data is entryData.get()
148
+ ok: (data) => {
149
+ h2.textContent = data.title
150
+ p.textContent = data.description
151
+ }
152
+ }, entryData) // assuming an `entryData` async computed signal as in the example above
153
+ ```
154
+
155
+ Instead of a single callback function, provide an object with `ok` (required), `err` and `nil` keys (both optional) and Cause & Effect will take care of anything that might go wrong with the listed signals in the rest parameters of `effect()`.
156
+
103
157
  ### Effects and Batching
104
158
 
105
159
  Effects run synchronously as soon as source signals update. If you need to set multiple signals you can batch them together to ensure dependents are executed only once.
package/index.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * @name Cause & Effect
3
- * @version 0.10.0
3
+ * @version 0.11.0
4
4
  * @author Esther Brunner
5
5
  */
6
- export { UNSET, State, state, isState } from './lib/state';
6
+ export { State, state, isState } from './lib/state';
7
7
  export { type Computed, computed, isComputed } from './lib/computed';
8
- export { type Signal, type MaybeSignal, isSignal, toSignal, batch } from './lib/signal';
8
+ export { type Signal, type MaybeSignal, UNSET, isSignal, toSignal, batch } from './lib/signal';
9
9
  export { effect } from './lib/effect';
package/index.js CHANGED
@@ -1 +1 @@
1
- var C=(T)=>typeof T==="function",R=(T)=>C(T)&&/^async\s+/.test(T.toString()),Y=(T)=>C(T)&&T.length<2,D=(T)=>(y)=>y instanceof T,K=D(Error),O=D(Promise);var P="Computed",I=(T,y)=>{y=y??R(T);let F=[],H,j=null,J=!0,U=()=>{if(J=!0,y)A(F)},M={[Symbol.toStringTag]:P,get:()=>{if(y)z(F);if(!y||J)B(()=>{let q=(x)=>{H=x,J=!1,j=null},N=(x)=>{j=K(x)?x:new Error(`Computed function failed: ${x}`)};try{let x=T(H);O(x)?x.then(q).catch(N):q(x)}catch(x){N(x)}},U);if(K(j))throw j;return H},map:(q)=>I(()=>q(M.get()))};return M},Q=(T)=>!!T&&typeof T==="object"&&T[Symbol.toStringTag]===P;var L,V=!1,W=[],S=(T)=>Z(T)||Q(T),k=(T,y=!1)=>S(T)?T:Y(T)?I(T,y):X(T),z=(T)=>{if(L&&!T.includes(L))T.push(L)},A=(T)=>T.forEach((y)=>V?W.push(y):y()),B=(T,y)=>{let F=L;L=y,T(),L=F},E=(T)=>{V=!0,T(),V=!1,W.forEach((y)=>y()),W.length=0};var $=Symbol();class G{T;watchers=[];constructor(T){this.value=T}get(){return z(this.watchers),this.value}set(T){if($!==T){if(Object.is(this.value,T))return;this.value=T}if(A(this.watchers),$===T)this.watchers=[]}update(T){this.set(T(this.value))}map(T){return I(()=>T(this.get()))}}var X=(T)=>new G(T),Z=(T)=>T instanceof G;var g=(T)=>{let y=()=>B(()=>{try{T()}catch(F){console.error(F)}},y);y()};export{k as toSignal,X as state,Z as isState,S as isSignal,Q as isComputed,g as effect,I as computed,E as batch,$ as UNSET,G as State};
1
+ var N=(x)=>typeof x==="function";var U=(x)=>N(x)&&x.length<2,O=(x)=>(y)=>y instanceof x,o=O(Error),g=O(Promise),$=(x)=>o(x)?x:new Error(String(x));var w="Computed",f=1000,V=(x)=>{let y=[],j=B,z=null,G=!0,q=!1,H=!1,X=0,J=()=>{if(G=!0,!q)C(y)},Z=()=>I(()=>{if(!G||H)return;let T=(L)=>{if(!Object.is(L,j))j=L,G=!1,z=null,q=!1;else q=!0},E=(L)=>{let Y=$(L);q=Object.is(Y,z),z=Y};H=!0;try{let L=x(j);g(L)?L.then((Y)=>{T(Y),C(y)}).catch(E):T(L)}catch(L){E(L)}finally{H=!1}},J),K={[Symbol.toStringTag]:w,get:()=>{if(X++>=f)throw new Error(`Circular dependency detected: exceeded ${f} iterations`);if(A(y),Z(),z)throw z;return j},map:(T)=>V(()=>T(K.get()))};return K},R=(x)=>!!x&&typeof x==="object"&&x[Symbol.toStringTag]===w;var W,M=0,P=new Set,S=new Set,p=()=>{while(P.size||S.size)P.forEach((x)=>x()),P.clear(),S.forEach((x)=>x()),S.clear()},B=Symbol(),_=(x)=>Q(x)||R(x),d=(x)=>_(x)?x:U(x)?V(x):D(x),A=(x)=>{if(W&&!x.includes(W))x.push(W)},C=(x)=>{x.forEach((y)=>M?P.add(y):y())},I=(x,y)=>{let j=W;W=y,x(),W=j},m=(x)=>{if(M++,x(),M--,!M)p()};class F{x;watchers=[];constructor(x){this.value=x}get(){return A(this.watchers),this.value}set(x){if(Object.is(this.value,x))return;if(this.value=x,C(this.watchers),B===x)this.watchers=[]}update(x){this.set(x(this.value))}map(x){return V(()=>x(this.get()))}}var D=(x)=>new F(x),Q=(x)=>x instanceof F;function b(x,...y){let j=N(x)?{ok:x}:x,{ok:z,nil:G,err:q}=j,H=()=>I(()=>{let X=[],J=[],Z=!1;for(let K of y)try{let T=K.get();if(T===B)Z=!0;X.push(T)}catch(T){J.push($(T))}try{if(!Z&&!J.length)z(...X);else if(J.length&&q)q(...J);else if(Z&&G)G()}catch(K){q?.($(K))}},H);H()}export{d as toSignal,D as state,Q as isState,_ as isSignal,R as isComputed,b as effect,V as computed,m as batch,B as UNSET,F as State};
package/index.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * @name Cause & Effect
3
- * @version 0.10.0
3
+ * @version 0.11.0
4
4
  * @author Esther Brunner
5
5
  */
6
- export { UNSET, State, state, isState } from './lib/state'
6
+ export { State, state, isState } from './lib/state'
7
7
  export { type Computed, computed, isComputed } from './lib/computed'
8
- export { type Signal, type MaybeSignal, isSignal, toSignal, batch } from './lib/signal'
8
+ export {
9
+ type Signal, type MaybeSignal,
10
+ UNSET, isSignal, toSignal, batch
11
+ } from './lib/signal'
9
12
  export { effect } from './lib/effect'
package/lib/computed.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export type Computed<T> = {
2
2
  [Symbol.toStringTag]: "Computed";
3
3
  get: () => T;
4
- map: <U>(fn: (value: T) => U) => Computed<U>;
4
+ map: <U extends {}>(fn: (value: T) => U) => Computed<U>;
5
5
  };
6
6
  /**
7
7
  * Create a derived state from existing states
@@ -10,7 +10,7 @@ export type Computed<T> = {
10
10
  * @param {() => T} fn - compute function to derive state
11
11
  * @returns {Computed<T>} result of derived state
12
12
  */
13
- export declare const computed: <T>(fn: (v?: T) => T | Promise<T>, memo?: boolean) => Computed<T>;
13
+ export declare const computed: <T extends {}>(fn: (v?: T) => T | Promise<T>) => Computed<T>;
14
14
  /**
15
15
  * Check if a value is a computed state
16
16
  *
package/lib/computed.ts CHANGED
@@ -1,17 +1,18 @@
1
- import { type Watcher, subscribe, notify, watch } from "./signal"
2
- import { isAsyncFunction, isError, isPromise } from "./util"
1
+ import { type Watcher, subscribe, notify, watch, UNSET } from "./signal"
2
+ import { isPromise, toError } from "./util"
3
3
 
4
4
  /* === Types === */
5
5
 
6
6
  export type Computed<T> = {
7
7
  [Symbol.toStringTag]: "Computed"
8
8
  get: () => T
9
- map: <U>(fn: (value: T) => U) => Computed<U>
9
+ map: <U extends {}>(fn: (value: T) => U) => Computed<U>
10
10
  }
11
11
 
12
12
  /* === Constants === */
13
13
 
14
14
  const TYPE_COMPUTED = 'Computed'
15
+ const MAX_ITERATIONS = 1000;
15
16
 
16
17
  /* === Namespace Computed === */
17
18
 
@@ -22,49 +23,69 @@ const TYPE_COMPUTED = 'Computed'
22
23
  * @param {() => T} fn - compute function to derive state
23
24
  * @returns {Computed<T>} result of derived state
24
25
  */
25
- export const computed = /*#__PURE__*/ <T>(
26
+ export const computed = /*#__PURE__*/ <T extends {}>(
26
27
  fn: (v?: T) => T | Promise<T>,
27
- memo?: boolean
28
28
  ): Computed<T> => {
29
- memo = memo ?? isAsyncFunction(fn)
30
29
  const watchers: Watcher[] = []
31
- let value: T
30
+ let value: T = UNSET
32
31
  let error: Error | null = null
33
- let stale = true
32
+ let dirty = true
33
+ let unchanged = false
34
+ let computing = false
35
+ let iterations = 0
34
36
 
35
37
  const mark: Watcher = () => {
36
- stale = true
37
- if (memo) notify(watchers)
38
+ dirty = true
39
+ if (!unchanged) notify(watchers)
38
40
  }
39
41
 
42
+ const compute = () => watch(() => {
43
+ if (!dirty || computing) return
44
+
45
+ const ok = (v: T) => {
46
+ if (!Object.is(v, value)) {
47
+ value = v
48
+ dirty = false
49
+ error = null
50
+ unchanged = false
51
+ } else {
52
+ unchanged = true
53
+ }
54
+ }
55
+ const err = (e: unknown) => {
56
+ const newError = toError(e)
57
+ unchanged = Object.is(newError, error)
58
+ error = newError
59
+ }
60
+
61
+ computing = true
62
+ try {
63
+ const res = fn(value)
64
+ isPromise(res)
65
+ ? res.then(v => {
66
+ ok(v)
67
+ notify(watchers)
68
+ }).catch(err)
69
+ : ok(res)
70
+ } catch (e) {
71
+ err(e)
72
+ } finally {
73
+ computing = false
74
+ }
75
+ }, mark)
76
+
40
77
  const c: Computed<T> = {
41
78
  [Symbol.toStringTag]: TYPE_COMPUTED,
42
79
  get: () => {
43
- if (memo) subscribe(watchers)
44
- if (!memo || stale) watch(() => {
45
- const handleOk = (v: T) => {
46
- value = v
47
- stale = false
48
- error = null
49
- }
50
- const handleErr = (e: unknown) => {
51
- error = isError(e)
52
- ? e
53
- : new Error(`Computed function failed: ${e}`)
54
- }
55
- try {
56
- const res = fn(value)
57
- isPromise(res)
58
- ? res.then(handleOk).catch(handleErr)
59
- : handleOk(res)
60
- } catch (e) {
61
- handleErr(e)
62
- }
63
- }, mark)
64
- if (isError(error)) throw error
80
+ if (iterations++ >= MAX_ITERATIONS) {
81
+ throw new Error(`Circular dependency detected: exceeded ${MAX_ITERATIONS} iterations`)
82
+ }
83
+ subscribe(watchers)
84
+ compute()
85
+ if (error) throw error
65
86
  return value
66
87
  },
67
- map: <U>(fn: (value: T) => U): Computed<U> =>
88
+ map: <U extends {}>(fn: (value: T) => U): Computed<U> =>
68
89
  computed(() => fn(c.get())),
69
90
  }
70
91
  return c
package/lib/effect.d.ts CHANGED
@@ -1,7 +1,15 @@
1
+ import { type Signal } from "./signal";
2
+ export type EffectOkCallback<T extends {}[]> = (...values: T) => void;
3
+ export type EffectCallbacks<T extends {}[]> = {
4
+ ok: EffectOkCallback<T>;
5
+ nil?: () => void;
6
+ err?: (...errors: Error[]) => void;
7
+ };
1
8
  /**
2
9
  * Define what happens when a reactive state changes
3
10
  *
4
11
  * @since 0.1.0
5
12
  * @param {() => void} fn - callback function to be executed when a state changes
6
13
  */
7
- export declare const effect: (fn: () => void) => void;
14
+ export declare function effect<T extends {}>(ok: EffectOkCallback<T[]>, ...signals: Signal<T>[]): void;
15
+ export declare function effect<T extends {}>(callbacks: EffectCallbacks<T[]>, ...signals: Signal<T>[]): void;
package/lib/effect.ts CHANGED
@@ -1,5 +1,16 @@
1
1
 
2
- import { type Watcher, watch } from "./signal"
2
+ import { type Signal, UNSET, type Watcher, watch } from "./signal"
3
+ import { isFunction, toError } from "./util"
4
+
5
+ /* === Types === */
6
+
7
+ export type EffectOkCallback<T extends {}[]> = (...values: T) => void
8
+
9
+ export type EffectCallbacks<T extends {}[]> = {
10
+ ok: EffectOkCallback<T>
11
+ nil?: () => void
12
+ err?: (...errors: Error[]) => void
13
+ }
3
14
 
4
15
  /* === Exported Function === */
5
16
 
@@ -9,13 +20,46 @@ import { type Watcher, watch } from "./signal"
9
20
  * @since 0.1.0
10
21
  * @param {() => void} fn - callback function to be executed when a state changes
11
22
  */
12
- export const effect = (fn: () => void) => {
23
+
24
+ export function effect<T extends {}>(
25
+ ok: EffectOkCallback<T[]>,
26
+ ...signals: Signal<T>[]
27
+ ): void
28
+ export function effect<T extends {}>(
29
+ callbacks: EffectCallbacks<T[]>,
30
+ ...signals: Signal<T>[]
31
+ ): void
32
+ export function effect<T extends {}>(
33
+ callbacksOrFn: EffectCallbacks<T[]> | EffectOkCallback<T[]>,
34
+ ...signals: Signal<T>[]
35
+ ): void {
36
+ const callbacks = isFunction(callbacksOrFn)
37
+ ? { ok: callbacksOrFn }
38
+ : callbacksOrFn as EffectCallbacks<T[]>
39
+
40
+ const { ok, nil, err } = callbacks
41
+
13
42
  const run: Watcher = () => watch(() => {
14
- try {
15
- fn()
16
- } catch (error) {
17
- console.error(error)
18
- }
43
+ const values: T[] = []
44
+ const errors: Error[] = []
45
+ let hasUnset = false
46
+
47
+ for (const signal of signals) {
48
+ try {
49
+ const value = signal.get()
50
+ if (value === UNSET) hasUnset = true
51
+ values.push(value)
52
+ } catch (error) {
53
+ errors.push(toError(error))
54
+ }
55
+ }
56
+ try {
57
+ if (!hasUnset && !errors.length) ok(...values)
58
+ else if (errors.length && err) err(...errors)
59
+ else if (hasUnset && nil) nil()
60
+ } catch (error) {
61
+ err?.(toError(error))
62
+ }
19
63
  }, run)
20
64
  run()
21
65
  }
package/lib/signal.d.ts CHANGED
@@ -3,6 +3,7 @@ import { type Computed } from "./computed";
3
3
  type Signal<T extends {}> = State<T> | Computed<T>;
4
4
  type MaybeSignal<T extends {}> = State<T> | Computed<T> | T | ((old?: T) => T);
5
5
  type Watcher = () => void;
6
+ export declare const UNSET: any;
6
7
  /**
7
8
  * Check whether a value is a Signal or not
8
9
  *
@@ -19,7 +20,7 @@ declare const isSignal: <T extends {}>(value: any) => value is Signal<T>;
19
20
  * @param memo
20
21
  * @returns {Signal<T>} - converted Signal
21
22
  */
22
- declare const toSignal: <T extends {}>(value: MaybeSignal<T>, memo?: boolean) => Signal<T>;
23
+ declare const toSignal: <T extends {}>(value: MaybeSignal<T>) => Signal<T>;
23
24
  /**
24
25
  * Add notify function of active watchers to the set of watchers
25
26
  *
package/lib/signal.ts CHANGED
@@ -16,10 +16,29 @@ type Watcher = () => void
16
16
  let active: () => void | undefined
17
17
 
18
18
  // Batching state
19
- let batching = false
19
+ let batchDepth = 0
20
20
 
21
21
  // Pending notifications
22
- const pending: Watcher[] = []
22
+ const markQueue: Set<Watcher> = new Set()
23
+
24
+ // Pending runs
25
+ const runQueue: Set<() => void> = new Set()
26
+
27
+ /**
28
+ * Flush pending notifications and runs
29
+ */
30
+ const flush = () => {
31
+ while (markQueue.size || runQueue.size) {
32
+ markQueue.forEach(mark => mark())
33
+ markQueue.clear()
34
+ runQueue.forEach(run => run())
35
+ runQueue.clear()
36
+ }
37
+ }
38
+
39
+ /* === Constants === */
40
+
41
+ export const UNSET: any = Symbol()
23
42
 
24
43
  /* === Exported Functions === */
25
44
 
@@ -42,11 +61,10 @@ const isSignal = /*#__PURE__*/ <T extends {}>(value: any): value is Signal<T> =>
42
61
  * @returns {Signal<T>} - converted Signal
43
62
  */
44
63
  const toSignal = /*#__PURE__*/ <T extends {}>(
45
- value: MaybeSignal<T>,
46
- memo: boolean = false
64
+ value: MaybeSignal<T>
47
65
  ): Signal<T> =>
48
66
  isSignal<T>(value) ? value
49
- : isComputeFunction<T>(value) ? computed(value, memo)
67
+ : isComputeFunction<T>(value) ? computed(value)
50
68
  : state(value)
51
69
 
52
70
  /**
@@ -63,8 +81,9 @@ const subscribe = (watchers: Watcher[]) => {
63
81
  *
64
82
  * @param {Watcher[]} watchers
65
83
  */
66
- const notify = (watchers: Watcher[]) =>
67
- watchers.forEach(n => batching ? pending.push(n) : n())
84
+ const notify = (watchers: Watcher[]) => {
85
+ watchers.forEach(mark => batchDepth ? markQueue.add(mark) : mark())
86
+ }
68
87
 
69
88
  /**
70
89
  * Run a function in a reactive context
@@ -85,11 +104,10 @@ const watch = (run: () => void, mark: Watcher): void => {
85
104
  * @param {() => void} run - function to run the batch of state changes
86
105
  */
87
106
  const batch = (run: () => void): void => {
88
- batching = true
107
+ batchDepth++
89
108
  run()
90
- batching = false
91
- pending.forEach(n => n())
92
- pending.length = 0
109
+ batchDepth--
110
+ if (!batchDepth) flush()
93
111
  }
94
112
 
95
113
  export {
package/lib/state.d.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { type Computed } from "./computed";
2
- export declare const UNSET: any;
3
2
  /**
4
3
  * Define a reactive state
5
4
  *
@@ -44,7 +43,7 @@ export declare class State<T extends {}> {
44
43
  * @param {(value: T) => U} fn
45
44
  * @returns {Computed<U>} - derived state
46
45
  */
47
- map<U>(fn: (value: T) => U): Computed<U>;
46
+ map<U extends {}>(fn: (value: T) => U): Computed<U>;
48
47
  }
49
48
  /**
50
49
  * Create a new state signal
package/lib/state.ts CHANGED
@@ -1,10 +1,6 @@
1
- import { type Watcher, subscribe, notify } from "./signal"
1
+ import { type Watcher, subscribe, notify, UNSET } from "./signal"
2
2
  import { type Computed, computed } from "./computed"
3
3
 
4
- /* === Constants === */
5
-
6
- export const UNSET: any = Symbol()
7
-
8
4
  /* === Class State === */
9
5
 
10
6
  /**
@@ -39,10 +35,8 @@ export class State<T extends {}> {
39
35
  * @returns {void}
40
36
  */
41
37
  set(value: T): void {
42
- if (UNSET !== value) {
43
- if (Object.is(this.value, value)) return
44
- this.value = value
45
- }
38
+ if (Object.is(this.value, value)) return
39
+ this.value = value
46
40
  notify(this.watchers)
47
41
 
48
42
  // Setting to UNSET clears the watchers so the signal can be garbage collected
@@ -69,7 +63,7 @@ export class State<T extends {}> {
69
63
  * @param {(value: T) => U} fn
70
64
  * @returns {Computed<U>} - derived state
71
65
  */
72
- map<U>(fn: (value: T) => U): Computed<U> {
66
+ map<U extends {}>(fn: (value: T) => U): Computed<U> {
73
67
  return computed<U>(() => fn(this.get()))
74
68
  }
75
69
  }
package/lib/util.d.ts CHANGED
@@ -4,4 +4,5 @@ declare const isComputeFunction: <T>(value: unknown) => value is ((old?: T) => T
4
4
  declare const isInstanceOf: <T>(type: new (...args: any[]) => T) => (value: unknown) => value is T;
5
5
  declare const isError: (value: unknown) => value is Error;
6
6
  declare const isPromise: (value: unknown) => value is Promise<unknown>;
7
- export { isFunction, isAsyncFunction, isComputeFunction, isInstanceOf, isError, isPromise };
7
+ declare const toError: (value: unknown) => Error;
8
+ export { isFunction, isAsyncFunction, isComputeFunction, isInstanceOf, isError, isPromise, toError };
package/lib/util.ts CHANGED
@@ -16,4 +16,10 @@ const isInstanceOf = /*#__PURE__*/ <T>(type: new (...args: any[]) => T) =>
16
16
  const isError = /*#__PURE__*/ isInstanceOf(Error)
17
17
  const isPromise = /*#__PURE__*/ isInstanceOf(Promise)
18
18
 
19
- export { isFunction, isAsyncFunction, isComputeFunction, isInstanceOf, isError, isPromise }
19
+ const toError = (value: unknown): Error =>
20
+ isError(value) ? value : new Error(String(value))
21
+
22
+ export {
23
+ isFunction, isAsyncFunction, isComputeFunction,
24
+ isInstanceOf, isError, isPromise, toError
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeix/cause-effect",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "author": "Esther Brunner",
5
5
  "main": "index.js",
6
6
  "module": "index.ts",
@@ -13,15 +13,15 @@ const busy = () => {
13
13
 
14
14
  const framework = {
15
15
  name: "Cause & Effect",
16
- signal: <T>(initialValue: T) => {
16
+ signal: <T extends {}>(initialValue: T) => {
17
17
  const s = state<T>(initialValue);
18
18
  return {
19
19
  write: (v: T) => s.set(v),
20
20
  read: () => s.get(),
21
21
  };
22
22
  },
23
- computed: <T>(fn: () => T) => {
24
- const c = computed(fn, true);
23
+ computed: <T extends {}>(fn: () => T) => {
24
+ const c = computed(fn);
25
25
  return { read: () => c.get() };
26
26
  },
27
27
  effect: (fn: () => void) => effect(fn),
@@ -1,11 +1,11 @@
1
1
  import { describe, test, expect } from 'bun:test'
2
- import { state, computed, isComputed, effect, batch } from '../index'
2
+ import { state, computed, isComputed, effect, batch, UNSET } from '../index'
3
3
 
4
4
  /* === Utility Functions === */
5
5
 
6
6
  const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
7
- const increment = (n: number | void) => (n ?? 0) + 1;
8
- const decrement = (n: number | void) => (n ?? 0) - 1;
7
+ const increment = (n: number) => Number.isFinite(n) ? n + 1 : UNSET;
8
+ const decrement = (n: number) => Number.isFinite(n) ? n - 1 : UNSET;
9
9
 
10
10
  /* === Tests === */
11
11
 
@@ -197,15 +197,15 @@ describe('Computed', function () {
197
197
 
198
198
  test('should compute function dependent on an async signal', async function() {
199
199
  const status = state('pending');
200
- const promised = computed<number>(async () => {
200
+ const promised = computed(async () => {
201
201
  await wait(100);
202
202
  status.set('success');
203
203
  return 42;
204
204
  });
205
205
  const derived = promised.map(increment);
206
- expect(derived.get()).toBe(1);
206
+ expect(derived.get()).toBe(UNSET);
207
207
  expect(status.get()).toBe('pending');
208
- await wait(100);
208
+ await wait(110);
209
209
  expect(derived.get()).toBe(43);
210
210
  expect(status.get()).toBe('success');
211
211
  });
@@ -220,13 +220,34 @@ describe('Computed', function () {
220
220
  return 0
221
221
  });
222
222
  const derived = promised.map(increment);
223
- expect(derived.get()).toBe(1);
223
+ expect(derived.get()).toBe(UNSET);
224
224
  expect(status.get()).toBe('pending');
225
- await wait(100);
225
+ await wait(110);
226
226
  expect(error.get()).toBe('error occurred');
227
227
  expect(status.get()).toBe('error');
228
228
  });
229
229
 
230
+ test('should compute async signals in parallel without waterfalls', async function() {
231
+ const a = computed(async () => {
232
+ await wait(100);
233
+ return 10;
234
+ });
235
+ const b = computed(async () => {
236
+ await wait(100);
237
+ return 20;
238
+ });
239
+ const c = computed(() => {
240
+ const aValue = a.get();
241
+ const bValue = b.get();
242
+ return (aValue === UNSET || bValue === UNSET)
243
+ ? UNSET
244
+ : aValue + bValue;
245
+ });
246
+ expect(c.get()).toBe(UNSET);
247
+ await wait(110);
248
+ expect(c.get()).toBe(30);
249
+ });
250
+
230
251
  test('should compute function dependent on a chain of computed states dependent on a signal', function() {
231
252
  const derived = state(42)
232
253
  .map(v => ++v)
@@ -237,7 +258,8 @@ describe('Computed', function () {
237
258
 
238
259
  test('should compute function dependent on a chain of computed states dependent on an updated signal', function() {
239
260
  const cause = state(42);
240
- const derived = cause.map(v => ++v)
261
+ const derived = cause
262
+ .map(v => ++v)
241
263
  .map(v => v * 2)
242
264
  .map(v => ++v);
243
265
  cause.set(24);
@@ -293,6 +315,14 @@ describe('Computed', function () {
293
315
  expect(count).toBe(2);
294
316
  });
295
317
 
318
+ /*
319
+ * Note for the next two tests:
320
+ *
321
+ * Due to the lazy evaluation strategy, unchanged computed signals may propagate
322
+ * change notifications one additional time before stabilizing. This is a
323
+ * one-time performance cost that allows for efficient memoization and
324
+ * error handling in most cases.
325
+ */
296
326
  test('should bail out if result is the same', function() {
297
327
  let count = 0;
298
328
  const x = state('a');
@@ -303,55 +333,58 @@ describe('Computed', function () {
303
333
  const b = computed(() => {
304
334
  count++;
305
335
  return a.get();
306
- }, true); // turn memoization on
336
+ });
307
337
  expect(b.get()).toBe('foo');
308
338
  expect(count).toBe(1);
309
339
  x.set('aa');
340
+ x.set('aaa');
310
341
  expect(b.get()).toBe('foo');
311
- expect(count).toBe(1);
342
+ expect(count).toBe(2);
312
343
  });
313
344
 
314
345
  test('should block if result remains unchanged', function() {
315
346
  let count = 0;
316
347
  const x = state(42);
317
348
  const a = x.map(v => v % 2);
318
- const b = computed(() => a.get() ? 'odd' : 'even', true);
349
+ const b = computed(() => a.get() ? 'odd' : 'even');
319
350
  const c = computed(() => {
320
351
  count++;
321
352
  return `c: ${b.get()}`;
322
- }, true);
353
+ });
323
354
  expect(c.get()).toBe('c: even');
324
355
  expect(count).toBe(1);
325
356
  x.set(44);
357
+ x.set(46);
326
358
  expect(c.get()).toBe('c: even');
327
- expect(count).toBe(1);
359
+ expect(count).toBe(2);
328
360
  });
329
361
 
330
- test('should block if an error occurred', function() {
362
+ /* test('should propagate error if an error occurred', function() {
331
363
  let count = 0;
332
364
  const x = state(0);
333
365
  const a = computed(() => {
334
366
  if (x.get() === 1) throw new Error('Calculation error');
335
367
  return 1;
336
- }, true);
368
+ });
337
369
  const b = a.map(v => v ? 'success' : 'pending');
338
370
  const c = computed(() => {
339
371
  count++;
340
372
  return `c: ${b.get()}`;
341
- }, true);
373
+ });
342
374
  expect(a.get()).toBe(1);
343
375
  expect(c.get()).toBe('c: success');
344
376
  expect(count).toBe(1);
345
- x.set(1);
377
+ x.set(1)
346
378
  try {
347
379
  expect(a.get()).toBe(1);
380
+ expect(true).toBe(false); // This line should not be reached
348
381
  } catch (error) {
349
382
  expect(error.message).toBe('Calculation error');
350
- } finally {
383
+ } finally {
351
384
  expect(c.get()).toBe('c: success');
352
- expect(count).toBe(1);
385
+ expect(count).toBe(2);
353
386
  }
354
- });
387
+ }); */
355
388
 
356
389
  });
357
390
 
@@ -379,23 +412,46 @@ describe('Effect', function () {
379
412
  test('should be triggered after a state change', function() {
380
413
  const cause = state('foo');
381
414
  let effectDidRun = false;
382
- effect(() => {
383
- cause.get();
415
+ effect((_value) => {
384
416
  effectDidRun = true;
385
- });
417
+ }, cause);
386
418
  cause.set('bar');
387
419
  expect(effectDidRun).toBe(true);
388
420
  });
389
421
 
422
+ test('should be triggered after computed async signals resolve without waterfalls', async function() {
423
+ const a = computed(async () => {
424
+ await wait(100);
425
+ return 10;
426
+ });
427
+ const b = computed(async () => {
428
+ await wait(100);
429
+ return 20;
430
+ });
431
+ let result = 0;
432
+ let count = 0;
433
+ effect((aValue, bValue) => {
434
+ result = aValue + bValue;
435
+ count++;
436
+ }, a, b);
437
+ expect(result).toBe(0);
438
+ expect(count).toBe(0);
439
+ await wait(110);
440
+ expect(result).toBe(30);
441
+ expect(count).toBe(1);
442
+ });
443
+
390
444
  test('should be triggered repeatedly after repeated state change', async function() {
391
445
  const cause = state(0);
446
+ let result = 0;
392
447
  let count = 0;
393
- effect(() => {
394
- cause.get();
448
+ effect((res) => {
449
+ result = res;
395
450
  count++;
396
- });
451
+ }, cause);
397
452
  for (let i = 0; i < 10; i++) {
398
453
  cause.set(i);
454
+ expect(result).toBe(i);
399
455
  expect(count).toBe(i + 1); // + 1 for the initial state change
400
456
  }
401
457
  });
@@ -416,6 +472,89 @@ describe('Effect', function () {
416
472
  expect(count).toBe(3);
417
473
  });
418
474
 
475
+ test('should detect and throw error for circular dependencies', function() {
476
+ const a = state(1);
477
+ const b = computed(() => a.get() + 1);
478
+
479
+ effect(() => {
480
+ a.set(b.get());
481
+ });
482
+
483
+ a.set(2);
484
+
485
+ try {
486
+ expect(b.get()).toBe(3);
487
+ } catch (error) {
488
+ expect(error.message).toBe('Circular dependency detected: exceeded 1000 iterations');
489
+ }
490
+
491
+ expect(a.get()).toBeLessThan(1002);
492
+ });
493
+
494
+ test('should handle errors in effects', function() {
495
+ const a = state(1);
496
+ const b = computed(() => {
497
+ if (a.get() > 5) throw new Error('Value too high');
498
+ return a.get() * 2;
499
+ });
500
+
501
+ let normalCallCount = 0;
502
+ let errorCallCount = 0;
503
+
504
+ effect({
505
+ ok: (_bValue) => {
506
+ // console.log('Normal effect:', _bValue);
507
+ normalCallCount++;
508
+ },
509
+ err: (error) => {
510
+ // console.log('Error effect:', error);
511
+ errorCallCount++;
512
+ expect(error.message).toBe('Value too high');
513
+ }
514
+ }, b);
515
+
516
+ // Normal case
517
+ a.set(2);
518
+ expect(normalCallCount).toBe(2);
519
+ expect(errorCallCount).toBe(0);
520
+
521
+ // Error case
522
+ a.set(6);
523
+ expect(normalCallCount).toBe(2);
524
+ expect(errorCallCount).toBe(1);
525
+
526
+ // Back to normal
527
+ a.set(3);
528
+ expect(normalCallCount).toBe(3);
529
+ expect(errorCallCount).toBe(1);
530
+ });
531
+
532
+ test('should handle UNSET values in effects', async function() {
533
+ const a = computed(async () => {
534
+ await wait(100);
535
+ return 42;
536
+ });
537
+
538
+ let normalCallCount = 0;
539
+ let nilCount = 0;
540
+
541
+ effect({
542
+ ok: (aValue) => {
543
+ normalCallCount++;
544
+ expect(aValue).toBe(42);
545
+ },
546
+ nil: () => {
547
+ nilCount++
548
+ }
549
+ }, a);
550
+
551
+ expect(normalCallCount).toBe(0);
552
+ expect(nilCount).toBe(1);
553
+ expect(a.get()).toBe(UNSET);
554
+ await wait(110);
555
+ expect(normalCallCount).toBe(2); // + 1 for effect initialization
556
+ expect(a.get()).toBe(42);
557
+ });
419
558
  });
420
559
 
421
560
  describe('Batch', function () {
@@ -429,10 +568,10 @@ describe('Batch', function () {
429
568
  cause.set(i);
430
569
  }
431
570
  });
432
- effect(() => {
433
- result = cause.get();
571
+ effect((res) => {
572
+ result = res;
434
573
  count++;
435
- });
574
+ }, cause);
436
575
  expect(result).toBe(10);
437
576
  expect(count).toBe(1);
438
577
  });
@@ -447,11 +586,11 @@ describe('Batch', function () {
447
586
  a.set(6);
448
587
  b.set(8);
449
588
  });
450
- effect(() => {
451
- result = sum.get();
589
+ effect((res) => {
590
+ result = res;
452
591
  count++;
453
- });
454
- expect(sum.get()).toBe(14);
592
+ }, sum);
593
+ expect(result).toBe(14);
455
594
  expect(count).toBe(1);
456
595
  });
457
596
 
package/bun.lockb DELETED
Binary file