@zeix/cause-effect 0.14.1 → 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/README.md +256 -27
- package/biome.json +35 -0
- package/index.d.ts +32 -7
- package/index.dev.js +629 -0
- package/index.js +1 -1
- package/index.ts +41 -21
- package/package.json +6 -7
- package/src/computed.ts +30 -21
- package/src/diff.ts +136 -0
- package/src/effect.ts +59 -49
- package/src/match.ts +57 -0
- package/src/resolve.ts +58 -0
- package/src/scheduler.ts +3 -3
- package/src/signal.ts +48 -15
- package/src/state.ts +4 -3
- package/src/store.ts +325 -0
- package/src/util.ts +57 -5
- package/test/batch.test.ts +29 -25
- package/test/benchmark.test.ts +81 -45
- package/test/computed.test.ts +43 -39
- package/test/diff.test.ts +638 -0
- package/test/effect.test.ts +657 -49
- package/test/match.test.ts +378 -0
- package/test/resolve.test.ts +156 -0
- package/test/state.test.ts +33 -33
- package/test/store.test.ts +719 -0
- package/test/util/framework-types.ts +2 -2
- package/test/util/perf-tests.ts +2 -2
- package/test/util/reactive-framework.ts +1 -1
- package/tsconfig.json +9 -10
- package/types/index.d.ts +15 -0
- package/{src → types/src}/computed.d.ts +2 -2
- package/types/src/diff.d.ts +27 -0
- package/types/src/effect.d.ts +16 -0
- package/types/src/match.d.ts +21 -0
- package/types/src/resolve.d.ts +29 -0
- package/{src → types/src}/scheduler.d.ts +2 -2
- package/types/src/signal.d.ts +40 -0
- package/{src → types/src}/state.d.ts +1 -1
- package/types/src/store.d.ts +57 -0
- package/types/src/util.d.ts +15 -0
- package/types/test-new-effect.d.ts +1 -0
- package/src/effect.d.ts +0 -17
- package/src/signal.d.ts +0 -26
- package/src/util.d.ts +0 -7
package/index.ts
CHANGED
|
@@ -1,36 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @name Cause & Effect
|
|
3
|
-
* @version 0.
|
|
3
|
+
* @version 0.15.0
|
|
4
4
|
* @author Esther Brunner
|
|
5
5
|
*/
|
|
6
|
-
|
|
7
|
-
export {
|
|
8
|
-
type Signal,
|
|
9
|
-
type MaybeSignal,
|
|
10
|
-
type SignalValues,
|
|
11
|
-
UNSET,
|
|
12
|
-
isSignal,
|
|
13
|
-
isComputedCallback,
|
|
14
|
-
toSignal,
|
|
15
|
-
} from './src/signal'
|
|
16
|
-
export { type State, TYPE_STATE, state, isState } from './src/state'
|
|
6
|
+
|
|
17
7
|
export {
|
|
18
8
|
type Computed,
|
|
19
9
|
type ComputedCallback,
|
|
20
|
-
TYPE_COMPUTED,
|
|
21
10
|
computed,
|
|
22
11
|
isComputed,
|
|
12
|
+
isComputedCallback,
|
|
13
|
+
TYPE_COMPUTED,
|
|
23
14
|
} from './src/computed'
|
|
24
|
-
export { type
|
|
15
|
+
export { type DiffResult, diff, isEqual, type UnknownRecord } from './src/diff'
|
|
16
|
+
export { type EffectCallback, effect, type MaybeCleanup } from './src/effect'
|
|
17
|
+
export { type MatchHandlers, match } from './src/match'
|
|
18
|
+
export { type ResolveResult, resolve } from './src/resolve'
|
|
25
19
|
export {
|
|
26
|
-
|
|
20
|
+
batch,
|
|
27
21
|
type Cleanup,
|
|
28
|
-
|
|
29
|
-
watch,
|
|
30
|
-
subscribe,
|
|
31
|
-
notify,
|
|
22
|
+
enqueue,
|
|
32
23
|
flush,
|
|
33
|
-
|
|
24
|
+
notify,
|
|
34
25
|
observe,
|
|
35
|
-
|
|
26
|
+
subscribe,
|
|
27
|
+
type Updater,
|
|
28
|
+
type Watcher,
|
|
29
|
+
watch,
|
|
36
30
|
} from './src/scheduler'
|
|
31
|
+
export {
|
|
32
|
+
isSignal,
|
|
33
|
+
type MaybeSignal,
|
|
34
|
+
type Signal,
|
|
35
|
+
type SignalValues,
|
|
36
|
+
toSignal,
|
|
37
|
+
UNSET,
|
|
38
|
+
} from './src/signal'
|
|
39
|
+
export { isState, type State, state, TYPE_STATE } from './src/state'
|
|
40
|
+
export {
|
|
41
|
+
isStore,
|
|
42
|
+
type Store,
|
|
43
|
+
type StoreAddEvent,
|
|
44
|
+
type StoreChangeEvent,
|
|
45
|
+
type StoreEventMap,
|
|
46
|
+
type StoreRemoveEvent,
|
|
47
|
+
store,
|
|
48
|
+
TYPE_STORE,
|
|
49
|
+
} from './src/store'
|
|
50
|
+
export {
|
|
51
|
+
CircularDependencyError,
|
|
52
|
+
isAbortError,
|
|
53
|
+
isAsyncFunction,
|
|
54
|
+
isFunction,
|
|
55
|
+
toError,
|
|
56
|
+
} from './src/util'
|
package/package.json
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeix/cause-effect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"author": "Esther Brunner",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"module": "index.ts",
|
|
7
7
|
"devDependencies": {
|
|
8
|
+
"@biomejs/biome": "2.1.4",
|
|
8
9
|
"@types/bun": "latest",
|
|
9
|
-
"
|
|
10
|
-
"random": "^5.4.0",
|
|
11
|
-
"typescript-eslint": "^8.32.1"
|
|
10
|
+
"random": "^5.4.1"
|
|
12
11
|
},
|
|
13
12
|
"peerDependencies": {
|
|
14
13
|
"typescript": "^5.6.3"
|
|
@@ -25,10 +24,10 @@
|
|
|
25
24
|
"access": "public"
|
|
26
25
|
},
|
|
27
26
|
"scripts": {
|
|
28
|
-
"build": "bun build index.ts --outdir ./ --minify &&
|
|
27
|
+
"build": "bunx tsc && bun build index.ts --outdir ./ --minify && bun build index.ts --outfile index.dev.js",
|
|
29
28
|
"test": "bun test",
|
|
30
|
-
"lint": "bunx
|
|
29
|
+
"lint": "bunx biome lint --write"
|
|
31
30
|
},
|
|
32
31
|
"type": "module",
|
|
33
|
-
"types": "index.d.ts"
|
|
32
|
+
"types": "types/index.d.ts"
|
|
34
33
|
}
|
package/src/computed.ts
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
|
+
import { isEqual } from './diff'
|
|
2
|
+
import {
|
|
3
|
+
flush,
|
|
4
|
+
notify,
|
|
5
|
+
observe,
|
|
6
|
+
subscribe,
|
|
7
|
+
type Watcher,
|
|
8
|
+
watch,
|
|
9
|
+
} from './scheduler'
|
|
10
|
+
import { UNSET } from './signal'
|
|
1
11
|
import {
|
|
2
12
|
CircularDependencyError,
|
|
13
|
+
isAbortError,
|
|
14
|
+
isAsyncFunction,
|
|
3
15
|
isFunction,
|
|
4
16
|
isObjectOfType,
|
|
5
17
|
toError,
|
|
6
18
|
} from './util'
|
|
7
|
-
import {
|
|
8
|
-
type Watcher,
|
|
9
|
-
watch,
|
|
10
|
-
subscribe,
|
|
11
|
-
notify,
|
|
12
|
-
flush,
|
|
13
|
-
observe,
|
|
14
|
-
} from './scheduler'
|
|
15
|
-
import { UNSET } from './signal'
|
|
16
19
|
|
|
17
20
|
/* === Types === */
|
|
18
21
|
|
|
@@ -20,7 +23,7 @@ type Computed<T extends {}> = {
|
|
|
20
23
|
[Symbol.toStringTag]: 'Computed'
|
|
21
24
|
get(): T
|
|
22
25
|
}
|
|
23
|
-
type ComputedCallback<T extends {} & { then?:
|
|
26
|
+
type ComputedCallback<T extends {} & { then?: undefined }> =
|
|
24
27
|
| ((abort: AbortSignal) => Promise<T>)
|
|
25
28
|
| (() => T)
|
|
26
29
|
|
|
@@ -49,20 +52,20 @@ const computed = <T extends {}>(fn: ComputedCallback<T>): Computed<T> => {
|
|
|
49
52
|
let computing = false
|
|
50
53
|
|
|
51
54
|
// Functions to update internal state
|
|
52
|
-
const ok = (v: T) => {
|
|
53
|
-
if (!
|
|
55
|
+
const ok = (v: T): undefined => {
|
|
56
|
+
if (!isEqual(v, value)) {
|
|
54
57
|
value = v
|
|
55
58
|
changed = true
|
|
56
59
|
}
|
|
57
60
|
error = undefined
|
|
58
61
|
dirty = false
|
|
59
62
|
}
|
|
60
|
-
const nil = () => {
|
|
63
|
+
const nil = (): undefined => {
|
|
61
64
|
changed = UNSET !== value
|
|
62
65
|
value = UNSET
|
|
63
66
|
error = undefined
|
|
64
67
|
}
|
|
65
|
-
const err = (e: unknown) => {
|
|
68
|
+
const err = (e: unknown): undefined => {
|
|
66
69
|
const newError = toError(e)
|
|
67
70
|
changed =
|
|
68
71
|
!error ||
|
|
@@ -83,25 +86,31 @@ const computed = <T extends {}>(fn: ComputedCallback<T>): Computed<T> => {
|
|
|
83
86
|
// Own watcher: called when notified from sources (push)
|
|
84
87
|
const mark = watch(() => {
|
|
85
88
|
dirty = true
|
|
86
|
-
controller?.abort(
|
|
89
|
+
controller?.abort()
|
|
87
90
|
if (watchers.size) notify(watchers)
|
|
88
91
|
else mark.cleanup()
|
|
89
92
|
})
|
|
93
|
+
mark.off(() => {
|
|
94
|
+
controller?.abort()
|
|
95
|
+
})
|
|
90
96
|
|
|
91
97
|
// Called when requested by dependencies (pull)
|
|
92
98
|
const compute = () =>
|
|
93
99
|
observe(() => {
|
|
94
100
|
if (computing) throw new CircularDependencyError('computed')
|
|
95
101
|
changed = false
|
|
96
|
-
if (
|
|
97
|
-
|
|
102
|
+
if (isAsyncFunction(fn)) {
|
|
103
|
+
// Return current value until promise resolves
|
|
104
|
+
if (controller) return value
|
|
98
105
|
controller = new AbortController()
|
|
99
106
|
controller.signal.addEventListener(
|
|
100
107
|
'abort',
|
|
101
108
|
() => {
|
|
102
109
|
computing = false
|
|
103
110
|
controller = undefined
|
|
104
|
-
|
|
111
|
+
|
|
112
|
+
// Retry computation with updated state
|
|
113
|
+
compute()
|
|
105
114
|
},
|
|
106
115
|
{
|
|
107
116
|
once: true,
|
|
@@ -113,7 +122,7 @@ const computed = <T extends {}>(fn: ComputedCallback<T>): Computed<T> => {
|
|
|
113
122
|
try {
|
|
114
123
|
result = controller ? fn(controller.signal) : (fn as () => T)()
|
|
115
124
|
} catch (e) {
|
|
116
|
-
if (e
|
|
125
|
+
if (isAbortError(e)) nil()
|
|
117
126
|
else err(e)
|
|
118
127
|
computing = false
|
|
119
128
|
return
|
|
@@ -169,10 +178,10 @@ const isComputedCallback = /*#__PURE__*/ <T extends {}>(
|
|
|
169
178
|
/* === Exports === */
|
|
170
179
|
|
|
171
180
|
export {
|
|
172
|
-
type Computed,
|
|
173
|
-
type ComputedCallback,
|
|
174
181
|
TYPE_COMPUTED,
|
|
175
182
|
computed,
|
|
176
183
|
isComputed,
|
|
177
184
|
isComputedCallback,
|
|
185
|
+
type Computed,
|
|
186
|
+
type ComputedCallback,
|
|
178
187
|
}
|
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,78 +1,88 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import { type Cleanup, observe, watch } from './scheduler'
|
|
2
|
+
import {
|
|
3
|
+
CircularDependencyError,
|
|
4
|
+
isAbortError,
|
|
5
|
+
isAsyncFunction,
|
|
6
|
+
isFunction,
|
|
7
|
+
} from './util'
|
|
4
8
|
|
|
5
9
|
/* === Types === */
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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 {
|
|
21
|
-
* @returns {Cleanup} -
|
|
28
|
+
* @param {EffectCallback} callback - Synchronous or asynchronous effect callback
|
|
29
|
+
* @returns {Cleanup} - Cleanup function for the effect
|
|
22
30
|
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
): Cleanup {
|
|
26
|
-
const {
|
|
27
|
-
signals,
|
|
28
|
-
ok,
|
|
29
|
-
err = console.error,
|
|
30
|
-
nil = () => {},
|
|
31
|
-
} = isFunction(matcher)
|
|
32
|
-
? { signals: [] as unknown as S, ok: matcher }
|
|
33
|
-
: matcher
|
|
34
|
-
|
|
31
|
+
const effect = (callback: EffectCallback): Cleanup => {
|
|
32
|
+
const isAsync = isAsyncFunction<MaybeCleanup>(callback)
|
|
35
33
|
let running = false
|
|
34
|
+
let controller: AbortController | undefined
|
|
35
|
+
|
|
36
36
|
const run = watch(() =>
|
|
37
37
|
observe(() => {
|
|
38
38
|
if (running) throw new CircularDependencyError('effect')
|
|
39
39
|
running = true
|
|
40
40
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const value = signal.get()
|
|
47
|
-
if (value === UNSET) pending = true
|
|
48
|
-
return value
|
|
49
|
-
} catch (e) {
|
|
50
|
-
errors.push(toError(e))
|
|
51
|
-
return UNSET
|
|
52
|
-
}
|
|
53
|
-
}) as SignalValues<S>
|
|
41
|
+
// Abort any previous async operations
|
|
42
|
+
controller?.abort()
|
|
43
|
+
controller = undefined
|
|
44
|
+
|
|
45
|
+
let cleanup: MaybeCleanup | Promise<MaybeCleanup>
|
|
54
46
|
|
|
55
|
-
// Effectful part
|
|
56
|
-
let cleanup: void | Cleanup = undefined
|
|
57
47
|
try {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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)
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
running = false
|
|
70
76
|
}, run),
|
|
71
77
|
)
|
|
78
|
+
|
|
72
79
|
run()
|
|
73
|
-
return () =>
|
|
80
|
+
return () => {
|
|
81
|
+
controller?.abort()
|
|
82
|
+
run.cleanup()
|
|
83
|
+
}
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
/* === Exports === */
|
|
77
87
|
|
|
78
|
-
export { type
|
|
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/scheduler.ts
CHANGED
|
@@ -8,7 +8,7 @@ type Watcher = {
|
|
|
8
8
|
cleanup(): void
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
type Updater = <T>() => T | boolean |
|
|
11
|
+
type Updater = <T>() => T | boolean | undefined
|
|
12
12
|
|
|
13
13
|
/* === Internal === */
|
|
14
14
|
|
|
@@ -145,8 +145,8 @@ const observe = (run: () => void, watcher?: Watcher): void => {
|
|
|
145
145
|
* @param {symbol} dedupe - Symbol for deduplication; if not provided, a unique Symbol is created ensuring the update is always executed
|
|
146
146
|
*/
|
|
147
147
|
const enqueue = <T>(fn: Updater, dedupe?: symbol) =>
|
|
148
|
-
new Promise<T | boolean |
|
|
149
|
-
updateMap.set(dedupe || Symbol(), () => {
|
|
148
|
+
new Promise<T | boolean | undefined>((resolve, reject) => {
|
|
149
|
+
updateMap.set(dedupe || Symbol(), (): undefined => {
|
|
150
150
|
try {
|
|
151
151
|
resolve(fn())
|
|
152
152
|
} catch (error) {
|