@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 +72 -18
- package/index.d.ts +3 -3
- package/index.js +1 -1
- package/index.ts +6 -3
- package/lib/computed.d.ts +2 -2
- package/lib/computed.ts +54 -33
- package/lib/effect.d.ts +9 -1
- package/lib/effect.ts +51 -7
- package/lib/signal.d.ts +2 -1
- package/lib/signal.ts +29 -11
- package/lib/state.d.ts +1 -2
- package/lib/state.ts +4 -10
- package/lib/util.d.ts +2 -1
- package/lib/util.ts +7 -1
- package/package.json +1 -1
- package/test/benchmark.test.ts +3 -3
- package/test/cause-effect.test.ts +173 -34
- package/bun.lockb +0 -0
package/README.md
CHANGED
|
@@ -1,14 +1,48 @@
|
|
|
1
1
|
# Cause & Effect
|
|
2
2
|
|
|
3
|
-
Version 0.
|
|
3
|
+
Version 0.11.0
|
|
4
4
|
|
|
5
|
-
**Cause & Effect**
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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 `
|
|
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.
|
|
3
|
+
* @version 0.11.0
|
|
4
4
|
* @author Esther Brunner
|
|
5
5
|
*/
|
|
6
|
-
export {
|
|
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
|
|
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.
|
|
3
|
+
* @version 0.11.0
|
|
4
4
|
* @author Esther Brunner
|
|
5
5
|
*/
|
|
6
|
-
export {
|
|
6
|
+
export { State, state, isState } from './lib/state'
|
|
7
7
|
export { type Computed, computed, isComputed } from './lib/computed'
|
|
8
|
-
export {
|
|
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
|
|
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 {
|
|
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
|
|
32
|
+
let dirty = true
|
|
33
|
+
let unchanged = false
|
|
34
|
+
let computing = false
|
|
35
|
+
let iterations = 0
|
|
34
36
|
|
|
35
37
|
const mark: Watcher = () => {
|
|
36
|
-
|
|
37
|
-
if (
|
|
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 (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
|
19
|
+
let batchDepth = 0
|
|
20
20
|
|
|
21
21
|
// Pending notifications
|
|
22
|
-
const
|
|
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
|
|
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(
|
|
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
|
-
|
|
107
|
+
batchDepth++
|
|
89
108
|
run()
|
|
90
|
-
|
|
91
|
-
|
|
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 (
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/test/benchmark.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
8
|
-
const decrement = (n: number
|
|
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
|
|
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(
|
|
206
|
+
expect(derived.get()).toBe(UNSET);
|
|
207
207
|
expect(status.get()).toBe('pending');
|
|
208
|
-
await wait(
|
|
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(
|
|
223
|
+
expect(derived.get()).toBe(UNSET);
|
|
224
224
|
expect(status.get()).toBe('pending');
|
|
225
|
-
await wait(
|
|
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
|
|
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
|
-
}
|
|
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(
|
|
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'
|
|
349
|
+
const b = computed(() => a.get() ? 'odd' : 'even');
|
|
319
350
|
const c = computed(() => {
|
|
320
351
|
count++;
|
|
321
352
|
return `c: ${b.get()}`;
|
|
322
|
-
}
|
|
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(
|
|
359
|
+
expect(count).toBe(2);
|
|
328
360
|
});
|
|
329
361
|
|
|
330
|
-
test('should
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
-
|
|
383
|
+
} finally {
|
|
351
384
|
expect(c.get()).toBe('c: success');
|
|
352
|
-
expect(count).toBe(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
589
|
+
effect((res) => {
|
|
590
|
+
result = res;
|
|
452
591
|
count++;
|
|
453
|
-
|
|
454
|
-
expect(
|
|
592
|
+
}, sum);
|
|
593
|
+
expect(result).toBe(14);
|
|
455
594
|
expect(count).toBe(1);
|
|
456
595
|
});
|
|
457
596
|
|
package/bun.lockb
DELETED
|
Binary file
|