@zeix/cause-effect 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,31 +1,34 @@
1
1
  {
2
- "name": "@zeix/cause-effect",
3
- "version": "0.13.1",
4
- "author": "Esther Brunner",
5
- "main": "index.js",
6
- "module": "index.ts",
7
- "devDependencies": {
8
- "@types/bun": "latest",
9
- "random": "^5.3.0"
10
- },
11
- "peerDependencies": {
12
- "typescript": "^5.6.3"
13
- },
14
- "description": "Cause & Effect - reactive state management with signals.",
15
- "license": "MIT",
16
- "keywords": [
17
- "Cause & Effect",
18
- "Reactivity",
19
- "Signals",
20
- "Effects"
21
- ],
22
- "publishConfig": {
23
- "access": "public"
24
- },
25
- "scripts": {
26
- "build": "bun build index.ts --outdir ./ --minify && bunx tsc",
27
- "test": "bun test"
28
- },
29
- "type": "module",
30
- "types": "index.d.ts"
2
+ "name": "@zeix/cause-effect",
3
+ "version": "0.14.0",
4
+ "author": "Esther Brunner",
5
+ "main": "index.js",
6
+ "module": "index.ts",
7
+ "devDependencies": {
8
+ "@types/bun": "latest",
9
+ "eslint": "^9.27.0",
10
+ "random": "^5.4.0",
11
+ "typescript-eslint": "^8.32.1"
12
+ },
13
+ "peerDependencies": {
14
+ "typescript": "^5.6.3"
15
+ },
16
+ "description": "Cause & Effect - reactive state management with signals.",
17
+ "license": "MIT",
18
+ "keywords": [
19
+ "Cause & Effect",
20
+ "Reactivity",
21
+ "Signals",
22
+ "Effects"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "build": "bun build index.ts --outdir ./ --minify && bunx tsc",
29
+ "test": "bun test",
30
+ "lint": "bunx eslint src/"
31
+ },
32
+ "type": "module",
33
+ "types": "index.d.ts"
31
34
  }
@@ -0,0 +1,31 @@
1
+ import { type MemoCallback } from './memo';
2
+ import { type TaskCallback } from './task';
3
+ type Computed<T extends {}> = {
4
+ [Symbol.toStringTag]: 'Computed';
5
+ get(): T;
6
+ };
7
+ type ComputedCallback<T extends {} & {
8
+ then?: void;
9
+ }> = TaskCallback<T> | MemoCallback<T>;
10
+ declare const TYPE_COMPUTED = "Computed";
11
+ /**
12
+ * Create a derived signal from existing signals
13
+ *
14
+ * This function delegates to either memo() for synchronous computations
15
+ * or task() for asynchronous computations, providing better performance
16
+ * for each case.
17
+ *
18
+ * @since 0.9.0
19
+ * @param {ComputedCallback<T>} fn - computation callback function
20
+ * @returns {Computed<T>} - Computed signal
21
+ */
22
+ declare const computed: <T extends {}>(fn: ComputedCallback<T>) => Computed<T>;
23
+ /**
24
+ * Check if a value is a computed state
25
+ *
26
+ * @since 0.9.0
27
+ * @param {unknown} value - value to check
28
+ * @returns {boolean} - true if value is a computed state, false otherwise
29
+ */
30
+ declare const isComputed: <T extends {}>(value: unknown) => value is Computed<T>;
31
+ export { type Computed, type ComputedCallback, TYPE_COMPUTED, computed, isComputed, };
@@ -0,0 +1,54 @@
1
+ import { isAsyncFunction, isObjectOfType } from './util'
2
+ import { type MemoCallback, memo } from './memo'
3
+ import { type TaskCallback, task } from './task'
4
+
5
+ /* === Types === */
6
+
7
+ type Computed<T extends {}> = {
8
+ [Symbol.toStringTag]: 'Computed'
9
+ get(): T
10
+ }
11
+ type ComputedCallback<T extends {} & { then?: void }> =
12
+ | TaskCallback<T>
13
+ | MemoCallback<T>
14
+
15
+ /* === Constants === */
16
+
17
+ const TYPE_COMPUTED = 'Computed'
18
+
19
+ /* === Functions === */
20
+
21
+ /**
22
+ * Create a derived signal from existing signals
23
+ *
24
+ * This function delegates to either memo() for synchronous computations
25
+ * or task() for asynchronous computations, providing better performance
26
+ * for each case.
27
+ *
28
+ * @since 0.9.0
29
+ * @param {ComputedCallback<T>} fn - computation callback function
30
+ * @returns {Computed<T>} - Computed signal
31
+ */
32
+ const computed = <T extends {}>(fn: ComputedCallback<T>): Computed<T> =>
33
+ isAsyncFunction<T>(fn) ? task<T>(fn) : memo<T>(fn as MemoCallback<T>)
34
+
35
+ /**
36
+ * Check if a value is a computed state
37
+ *
38
+ * @since 0.9.0
39
+ * @param {unknown} value - value to check
40
+ * @returns {boolean} - true if value is a computed state, false otherwise
41
+ */
42
+ const isComputed = /*#__PURE__*/ <T extends {}>(
43
+ value: unknown,
44
+ ): value is Computed<T> => isObjectOfType(value, TYPE_COMPUTED)
45
+
46
+ /* === Exports === */
47
+
48
+ export {
49
+ type Computed,
50
+ type ComputedCallback,
51
+ TYPE_COMPUTED,
52
+ computed,
53
+ isComputed,
54
+ }
@@ -0,0 +1,19 @@
1
+ import { type Signal } from './signal';
2
+ import { type Cleanup } from './scheduler';
3
+ type EffectMatcher<S extends Signal<{}>[]> = {
4
+ signals: S;
5
+ ok: (...values: {
6
+ [K in keyof S]: S[K] extends Signal<infer T> ? T : never;
7
+ }) => void | Cleanup;
8
+ err?: (...errors: Error[]) => void | Cleanup;
9
+ nil?: () => void | Cleanup;
10
+ };
11
+ /**
12
+ * Define what happens when a reactive state changes
13
+ *
14
+ * @since 0.1.0
15
+ * @param {EffectMatcher<S> | (() => void | Cleanup)} matcher - effect matcher or callback
16
+ * @returns {Cleanup} - cleanup function for the effect
17
+ */
18
+ declare function effect<S extends Signal<{}>[]>(matcher: EffectMatcher<S> | (() => void | Cleanup)): Cleanup;
19
+ export { type EffectMatcher, effect };
package/src/effect.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { type Signal, UNSET } from './signal'
2
+ import {
3
+ CircularDependencyError,
4
+ isFunction,
5
+ toError,
6
+ isAbortError,
7
+ } from './util'
8
+ import { watch, type Cleanup, type Watcher } from './scheduler'
9
+
10
+ /* === Types === */
11
+
12
+ type EffectMatcher<S extends Signal<{}>[]> = {
13
+ signals: S
14
+ ok: (
15
+ ...values: {
16
+ [K in keyof S]: S[K] extends Signal<infer T> ? T : never
17
+ }
18
+ ) => void | Cleanup
19
+ err?: (...errors: Error[]) => void | Cleanup
20
+ nil?: () => void | Cleanup
21
+ }
22
+
23
+ /* === Functions === */
24
+
25
+ /**
26
+ * Define what happens when a reactive state changes
27
+ *
28
+ * @since 0.1.0
29
+ * @param {EffectMatcher<S> | (() => void | Cleanup)} matcher - effect matcher or callback
30
+ * @returns {Cleanup} - cleanup function for the effect
31
+ */
32
+ function effect<S extends Signal<{}>[]>(
33
+ matcher: EffectMatcher<S> | (() => void | Cleanup),
34
+ ): Cleanup {
35
+ const {
36
+ signals,
37
+ ok,
38
+ err = console.error,
39
+ nil = () => {},
40
+ } = isFunction(matcher)
41
+ ? { signals: [] as unknown as S, ok: matcher }
42
+ : matcher
43
+ let running = false
44
+ const run = (() =>
45
+ watch(() => {
46
+ if (running) throw new CircularDependencyError('effect')
47
+ running = true
48
+ let cleanup: void | Cleanup = undefined
49
+ try {
50
+ const errors: Error[] = []
51
+ let suspense = false
52
+ const values = signals.map(signal => {
53
+ try {
54
+ const value = signal.get()
55
+ if (value === UNSET) suspense = true
56
+ return value
57
+ } catch (e) {
58
+ if (isAbortError(e)) throw e
59
+ errors.push(toError(e))
60
+ return UNSET
61
+ }
62
+ }) as {
63
+ [K in keyof S]: S[K] extends Signal<infer T extends {}>
64
+ ? T
65
+ : never
66
+ }
67
+
68
+ try {
69
+ cleanup = suspense
70
+ ? nil()
71
+ : errors.length
72
+ ? err(...errors)
73
+ : ok(...values)
74
+ } catch (e) {
75
+ if (isAbortError(e)) throw e
76
+ const error = toError(e)
77
+ cleanup = err(error)
78
+ }
79
+ } catch (e) {
80
+ err(toError(e))
81
+ }
82
+ if (isFunction(cleanup)) run.cleanups.add(cleanup)
83
+ running = false
84
+ }, run)) as Watcher
85
+ run.cleanups = new Set<Cleanup>()
86
+ run()
87
+ return () => {
88
+ run.cleanups.forEach(fn => fn())
89
+ run.cleanups.clear()
90
+ }
91
+ }
92
+
93
+ /* === Exports === */
94
+
95
+ export { type EffectMatcher, effect }
package/src/memo.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { type Computed } from './computed';
2
+ type MemoCallback<T extends {} & {
3
+ then?: void;
4
+ }> = () => T;
5
+ /**
6
+ * Create a derived signal for synchronous computations
7
+ *
8
+ * @since 0.14.0
9
+ * @param {MemoCallback<T>} fn - synchronous computation callback
10
+ * @returns {Computed<T>} - Computed signal
11
+ */
12
+ declare const memo: <T extends {}>(fn: MemoCallback<T>) => Computed<T>;
13
+ export { type MemoCallback, memo };
package/src/memo.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { UNSET } from './signal'
2
+ import { CircularDependencyError } from './util'
3
+ import {
4
+ type Cleanup,
5
+ type Watcher,
6
+ flush,
7
+ notify,
8
+ subscribe,
9
+ watch,
10
+ } from './scheduler'
11
+ import { type Computed, TYPE_COMPUTED } from './computed'
12
+
13
+ /* === Types === */
14
+
15
+ type MemoCallback<T extends {} & { then?: void }> = () => T
16
+
17
+ /* === Functions === */
18
+
19
+ /**
20
+ * Create a derived signal for synchronous computations
21
+ *
22
+ * @since 0.14.0
23
+ * @param {MemoCallback<T>} fn - synchronous computation callback
24
+ * @returns {Computed<T>} - Computed signal
25
+ */
26
+ const memo = <T extends {}>(fn: MemoCallback<T>): Computed<T> => {
27
+ const watchers: Set<Watcher> = new Set()
28
+
29
+ // Internal state - simplified for sync only
30
+ let value: T = UNSET
31
+ let error: Error | undefined
32
+ let dirty = true
33
+ let computing = false
34
+
35
+ // Called when notified from sources (push)
36
+ const mark = (() => {
37
+ dirty = true
38
+ if (watchers.size) {
39
+ notify(watchers)
40
+ } else {
41
+ mark.cleanups.forEach(fn => fn())
42
+ mark.cleanups.clear()
43
+ }
44
+ }) as Watcher
45
+ mark.cleanups = new Set<Cleanup>()
46
+
47
+ // Called when requested by dependencies (pull)
48
+ const compute = () =>
49
+ watch(() => {
50
+ if (computing) throw new CircularDependencyError('memo')
51
+ computing = true
52
+ try {
53
+ const result = fn()
54
+ if (null == result || UNSET === result) {
55
+ value = UNSET
56
+ error = undefined
57
+ } else {
58
+ value = result
59
+ dirty = false
60
+ error = undefined
61
+ }
62
+ } catch (e) {
63
+ value = UNSET
64
+ error = e instanceof Error ? e : new Error(String(e))
65
+ } finally {
66
+ computing = false
67
+ }
68
+ }, mark)
69
+
70
+ const c: Computed<T> = {
71
+ [Symbol.toStringTag]: TYPE_COMPUTED,
72
+
73
+ /**
74
+ * Get the current value of the computed
75
+ *
76
+ * @returns {T} - current value of the computed
77
+ */
78
+ get: (): T => {
79
+ subscribe(watchers)
80
+ flush()
81
+ if (dirty) compute()
82
+ if (error) throw error
83
+ return value
84
+ },
85
+ }
86
+ return c
87
+ }
88
+
89
+ /* === Exports === */
90
+
91
+ export { type MemoCallback, memo }
@@ -1,42 +1,46 @@
1
- export type EnqueueDedupe = [Element, string];
2
- export type Watcher = {
1
+ type Cleanup = () => void;
2
+ type Watcher = {
3
3
  (): void;
4
- cleanups: Set<() => void>;
4
+ cleanups: Set<Cleanup>;
5
5
  };
6
- export type Updater = <T>() => T | boolean | void;
6
+ type Updater = <T>() => T | boolean | void;
7
7
  /**
8
8
  * Add active watcher to the Set of watchers
9
9
  *
10
10
  * @param {Set<Watcher>} watchers - watchers of the signal
11
11
  */
12
- export declare const subscribe: (watchers: Set<Watcher>) => void;
12
+ declare const subscribe: (watchers: Set<Watcher>) => void;
13
13
  /**
14
14
  * Add watchers to the pending set of change notifications
15
15
  *
16
16
  * @param {Set<Watcher>} watchers - watchers of the signal
17
17
  */
18
- export declare const notify: (watchers: Set<Watcher>) => void;
18
+ declare const notify: (watchers: Set<Watcher>) => void;
19
19
  /**
20
20
  * Flush all pending changes to notify watchers
21
21
  */
22
- export declare const flush: () => void;
22
+ declare const flush: () => void;
23
23
  /**
24
24
  * Batch multiple changes in a single signal graph and DOM update cycle
25
25
  *
26
26
  * @param {() => void} fn - function with multiple signal writes to be batched
27
27
  */
28
- export declare const batch: (fn: () => void) => void;
28
+ declare const batch: (fn: () => void) => void;
29
29
  /**
30
30
  * Run a function in a reactive context
31
31
  *
32
32
  * @param {() => void} run - function to run the computation or effect
33
33
  * @param {Watcher} mark - function to be called when the state changes or undefined for temporary unwatching while inserting auto-hydrating DOM nodes that might read signals (e.g., web components)
34
34
  */
35
- export declare const watch: (run: () => void, mark?: Watcher) => void;
35
+ declare const watch: (run: () => void, mark?: Watcher) => void;
36
36
  /**
37
37
  * Enqueue a function to be executed on the next animation frame
38
38
  *
39
+ * If the same Symbol is provided for multiple calls before the next animation frame,
40
+ * only the latest call will be executed (deduplication).
41
+ *
39
42
  * @param {Updater} fn - function to be executed on the next animation frame; can return updated value <T>, success <boolean> or void
40
- * @param {EnqueueDedupe} dedupe - [element, operation] pair for deduplication
43
+ * @param {symbol} dedupe - Symbol for deduplication; if not provided, a unique Symbol is created ensuring the update is always executed
41
44
  */
42
- export declare const enqueue: <T>(fn: Updater, dedupe?: EnqueueDedupe) => Promise<boolean | void | T>;
45
+ declare const enqueue: <T>(fn: Updater, dedupe?: symbol) => Promise<boolean | void | T>;
46
+ export { type Cleanup, type Watcher, type Updater, subscribe, notify, flush, batch, watch, enqueue, };
@@ -1,13 +1,13 @@
1
1
  /* === Types === */
2
2
 
3
- export type EnqueueDedupe = [Element, string]
3
+ type Cleanup = () => void
4
4
 
5
- export type Watcher = {
6
- (): void,
7
- cleanups: Set<() => void>
5
+ type Watcher = {
6
+ (): void
7
+ cleanups: Set<Cleanup>
8
8
  }
9
9
 
10
- export type Updater = <T>() => T | boolean | void
10
+ type Updater = <T>() => T | boolean | void
11
11
 
12
12
  /* === Internal === */
13
13
 
@@ -18,8 +18,8 @@ let active: Watcher | undefined
18
18
  const pending = new Set<Watcher>()
19
19
  let batchDepth = 0
20
20
 
21
- // Map of DOM elements to update functions
22
- const updateMap = new Map<EnqueueDedupe, () => void>()
21
+ // Map of deduplication symbols to update functions (using Symbol keys prevents unintended overwrites)
22
+ const updateMap = new Map<symbol, Updater>()
23
23
  let requestId: number | undefined
24
24
 
25
25
  const updateDOM = () => {
@@ -32,21 +32,21 @@ const updateDOM = () => {
32
32
  }
33
33
 
34
34
  const requestTick = () => {
35
- if (requestId) cancelAnimationFrame(requestId)
36
- requestId = requestAnimationFrame(updateDOM)
35
+ if (requestId) cancelAnimationFrame(requestId)
36
+ requestId = requestAnimationFrame(updateDOM)
37
37
  }
38
38
 
39
39
  // Initial render when the call stack is empty
40
40
  queueMicrotask(updateDOM)
41
41
 
42
- /* === Exported Functions === */
42
+ /* === Functions === */
43
43
 
44
44
  /**
45
45
  * Add active watcher to the Set of watchers
46
- *
46
+ *
47
47
  * @param {Set<Watcher>} watchers - watchers of the signal
48
48
  */
49
- export const subscribe = (watchers: Set<Watcher>) => {
49
+ const subscribe = (watchers: Set<Watcher>) => {
50
50
  // if (!active) console.warn('Calling .get() outside of a reactive context')
51
51
  if (active && !watchers.has(active)) {
52
52
  const watcher = active
@@ -59,51 +59,51 @@ export const subscribe = (watchers: Set<Watcher>) => {
59
59
 
60
60
  /**
61
61
  * Add watchers to the pending set of change notifications
62
- *
62
+ *
63
63
  * @param {Set<Watcher>} watchers - watchers of the signal
64
64
  */
65
- export const notify = (watchers: Set<Watcher>) => {
65
+ const notify = (watchers: Set<Watcher>) => {
66
66
  for (const mark of watchers) {
67
- if (batchDepth) pending.add(mark)
67
+ if (batchDepth) pending.add(mark)
68
68
  else mark()
69
- }
69
+ }
70
70
  }
71
71
 
72
72
  /**
73
73
  * Flush all pending changes to notify watchers
74
74
  */
75
- export const flush = () => {
76
- while (pending.size) {
77
- const watchers = Array.from(pending)
78
- pending.clear()
79
- for (const mark of watchers) {
80
- mark()
81
- }
82
- }
75
+ const flush = () => {
76
+ while (pending.size) {
77
+ const watchers = Array.from(pending)
78
+ pending.clear()
79
+ for (const mark of watchers) {
80
+ mark()
81
+ }
82
+ }
83
83
  }
84
84
 
85
85
  /**
86
86
  * Batch multiple changes in a single signal graph and DOM update cycle
87
- *
87
+ *
88
88
  * @param {() => void} fn - function with multiple signal writes to be batched
89
89
  */
90
- export const batch = (fn: () => void) => {
90
+ const batch = (fn: () => void) => {
91
91
  batchDepth++
92
92
  try {
93
93
  fn()
94
94
  } finally {
95
95
  flush()
96
- batchDepth--
97
- }
96
+ batchDepth--
97
+ }
98
98
  }
99
99
 
100
100
  /**
101
101
  * Run a function in a reactive context
102
- *
102
+ *
103
103
  * @param {() => void} run - function to run the computation or effect
104
104
  * @param {Watcher} mark - function to be called when the state changes or undefined for temporary unwatching while inserting auto-hydrating DOM nodes that might read signals (e.g., web components)
105
105
  */
106
- export const watch = (run: () => void, mark?: Watcher): void => {
106
+ const watch = (run: () => void, mark?: Watcher): void => {
107
107
  const prev = active
108
108
  active = mark
109
109
  try {
@@ -115,23 +115,35 @@ export const watch = (run: () => void, mark?: Watcher): void => {
115
115
 
116
116
  /**
117
117
  * Enqueue a function to be executed on the next animation frame
118
- *
118
+ *
119
+ * If the same Symbol is provided for multiple calls before the next animation frame,
120
+ * only the latest call will be executed (deduplication).
121
+ *
119
122
  * @param {Updater} fn - function to be executed on the next animation frame; can return updated value <T>, success <boolean> or void
120
- * @param {EnqueueDedupe} dedupe - [element, operation] pair for deduplication
123
+ * @param {symbol} dedupe - Symbol for deduplication; if not provided, a unique Symbol is created ensuring the update is always executed
121
124
  */
122
- export const enqueue = <T>(
123
- fn: Updater,
124
- dedupe?: EnqueueDedupe
125
- ) => new Promise<T | boolean | void>((resolve, reject) => {
126
- const wrappedCallback = () => {
127
- try {
128
- resolve(fn())
129
- } catch (error) {
130
- reject(error)
131
- }
132
- }
133
- if (dedupe) {
134
- updateMap.set(dedupe, wrappedCallback)
135
- }
136
- requestTick()
137
- })
125
+ const enqueue = <T>(fn: Updater, dedupe?: symbol) =>
126
+ new Promise<T | boolean | void>((resolve, reject) => {
127
+ updateMap.set(dedupe || Symbol(), () => {
128
+ try {
129
+ resolve(fn())
130
+ } catch (error) {
131
+ reject(error)
132
+ }
133
+ })
134
+ requestTick()
135
+ })
136
+
137
+ /* === Exports === */
138
+
139
+ export {
140
+ type Cleanup,
141
+ type Watcher,
142
+ type Updater,
143
+ subscribe,
144
+ notify,
145
+ flush,
146
+ batch,
147
+ watch,
148
+ enqueue,
149
+ }
@@ -0,0 +1,31 @@
1
+ import { type ComputedCallback } from './computed';
2
+ type Signal<T extends {}> = {
3
+ get(): T;
4
+ };
5
+ type MaybeSignal<T extends {}> = T | Signal<T> | ComputedCallback<T>;
6
+ declare const UNSET: any;
7
+ /**
8
+ * Check whether a value is a Signal or not
9
+ *
10
+ * @since 0.9.0
11
+ * @param {unknown} value - value to check
12
+ * @returns {boolean} - true if value is a Signal, false otherwise
13
+ */
14
+ declare const isSignal: <T extends {}>(value: unknown) => value is Signal<T>;
15
+ /**
16
+ * Check if the provided value is a callback that may be used as input for toSignal() to derive a computed state
17
+ *
18
+ * @since 0.12.0
19
+ * @param {unknown} value - value to check
20
+ * @returns {boolean} - true if value is a callback or callbacks object, false otherwise
21
+ */
22
+ declare const isComputedCallback: <T extends {}>(value: unknown) => value is ComputedCallback<T>;
23
+ /**
24
+ * Convert a value to a Signal if it's not already a Signal
25
+ *
26
+ * @since 0.9.6
27
+ * @param {MaybeSignal<T>} value - value to convert to a Signal
28
+ * @returns {Signal<T>} - converted Signal
29
+ */
30
+ declare const toSignal: <T extends {}>(value: MaybeSignal<T>) => Signal<T>;
31
+ export { type Signal, type MaybeSignal, UNSET, isSignal, isComputedCallback, toSignal, };