@zeix/cause-effect 0.10.1 → 0.12.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.
@@ -0,0 +1,157 @@
1
+ import { describe, test, expect, mock } from 'bun:test'
2
+ import { state, computed, effect, UNSET } from '../'
3
+
4
+ /* === Utility Functions === */
5
+
6
+ const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
7
+
8
+ /* === Tests === */
9
+
10
+ describe('Effect', function () {
11
+
12
+ test('should be triggered after a state change', function() {
13
+ const cause = state('foo');
14
+ let count = 0;
15
+ effect((_value) => {
16
+ count++;
17
+ }, cause);
18
+ expect(count).toBe(1);
19
+ cause.set('bar');
20
+ expect(count).toBe(2);
21
+ });
22
+
23
+ test('should be triggered after computed async signals resolve without waterfalls', async function() {
24
+ const a = computed(async () => {
25
+ await wait(100);
26
+ return 10;
27
+ });
28
+ const b = computed(async () => {
29
+ await wait(100);
30
+ return 20;
31
+ });
32
+ let result = 0;
33
+ let count = 0;
34
+ effect((aValue, bValue) => {
35
+ result = aValue + bValue;
36
+ count++;
37
+ }, a, b);
38
+ expect(result).toBe(0);
39
+ expect(count).toBe(0);
40
+ await wait(110);
41
+ expect(result).toBe(30);
42
+ expect(count).toBe(1);
43
+ });
44
+
45
+ test('should be triggered repeatedly after repeated state change', async function() {
46
+ const cause = state(0);
47
+ let result = 0;
48
+ let count = 0;
49
+ effect((res) => {
50
+ result = res;
51
+ count++;
52
+ }, cause);
53
+ for (let i = 0; i < 10; i++) {
54
+ cause.set(i);
55
+ expect(result).toBe(i);
56
+ expect(count).toBe(i + 1); // + 1 for effect initialization
57
+ }
58
+ });
59
+
60
+ test('should handle errors in effects', function() {
61
+ const a = state(1);
62
+ const b = computed(() => {
63
+ if (a.get() > 5) throw new Error('Value too high');
64
+ return a.get() * 2;
65
+ });
66
+ let normalCallCount = 0;
67
+ let errorCallCount = 0;
68
+ effect({
69
+ ok: (_bValue) => {
70
+ // console.log('Normal effect:', _bValue);
71
+ normalCallCount++;
72
+ },
73
+ err: (error) => {
74
+ // console.log('Error effect:', error);
75
+ errorCallCount++;
76
+ expect(error.message).toBe('Value too high');
77
+ }
78
+ }, b);
79
+
80
+ // Normal case
81
+ a.set(2);
82
+ expect(normalCallCount).toBe(2);
83
+ expect(errorCallCount).toBe(0);
84
+
85
+ // Error case
86
+ a.set(6);
87
+ expect(normalCallCount).toBe(2);
88
+ expect(errorCallCount).toBe(1);
89
+
90
+ // Back to normal
91
+ a.set(3);
92
+ expect(normalCallCount).toBe(3);
93
+ expect(errorCallCount).toBe(1);
94
+ });
95
+
96
+ test('should handle UNSET values in effects', async function() {
97
+ const a = computed(async () => {
98
+ await wait(100);
99
+ return 42;
100
+ });
101
+ let normalCallCount = 0;
102
+ let nilCount = 0;
103
+ effect({
104
+ ok: (aValue) => {
105
+ normalCallCount++;
106
+ expect(aValue).toBe(42);
107
+ },
108
+ nil: () => {
109
+ nilCount++
110
+ }
111
+ }, a);
112
+
113
+ expect(normalCallCount).toBe(0);
114
+ expect(nilCount).toBe(1);
115
+ expect(a.get()).toBe(UNSET);
116
+ await wait(110);
117
+ expect(normalCallCount).toBe(2); // + 1 for effect initialization
118
+ expect(a.get()).toBe(42);
119
+ });
120
+
121
+ test('should log error to console when error is not handled', () => {
122
+ // Mock console.error
123
+ const originalConsoleError = console.error;
124
+ const mockConsoleError = mock((message: string, error: Error) => {});
125
+ console.error = mockConsoleError;
126
+
127
+ try {
128
+ const a = state(1);
129
+ const b = computed(() => {
130
+ if (a.get() > 5) throw new Error('Value too high');
131
+ return a.get() * 2;
132
+ });
133
+
134
+ // Create an effect without explicit error handling
135
+ effect(() => {
136
+ b.get();
137
+ });
138
+
139
+ // This should trigger the error
140
+ a.set(6);
141
+
142
+ // Check if console.error was called with the expected message
143
+ expect(mockConsoleError).toHaveBeenCalledWith(
144
+ 'Unhandled error in effect:',
145
+ expect.any(Error)
146
+ );
147
+
148
+ // Check the error message
149
+ const [, error] = mockConsoleError.mock.calls[0];
150
+ expect(error.message).toBe('Value too high');
151
+
152
+ } finally {
153
+ // Restore the original console.error
154
+ console.error = originalConsoleError;
155
+ }
156
+ });
157
+ });
@@ -0,0 +1,199 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { state, isComputed, UNSET } from '../'
3
+
4
+ /* === Tests === */
5
+
6
+ describe('State', function () {
7
+
8
+ describe('Boolean cause', function () {
9
+
10
+ test('should be boolean', function () {
11
+ const cause = state(false);
12
+ expect(typeof cause.get()).toBe('boolean');
13
+ });
14
+
15
+ test('should set initial value to false', function () {
16
+ const cause = state(false);
17
+ expect(cause.get()).toBe(false);
18
+ });
19
+
20
+ test('should set initial value to true', function () {
21
+ const cause = state(true);
22
+ expect(cause.get()).toBe(true);
23
+ });
24
+
25
+ test('should set new value with .set(true)', function () {
26
+ const cause = state(false);
27
+ cause.set(true);
28
+ expect(cause.get()).toBe(true);
29
+ });
30
+
31
+ test('should toggle initial value with .set(v => !v)', function () {
32
+ const cause = state(false);
33
+ cause.update((v) => !v);
34
+ expect(cause.get()).toBe(true);
35
+ });
36
+
37
+ });
38
+
39
+ describe('Number cause', function () {
40
+
41
+ test('should be number', function () {
42
+ const cause = state(0);
43
+ expect(typeof cause.get()).toBe('number');
44
+ });
45
+
46
+ test('should set initial value to 0', function () {
47
+ const cause = state(0);
48
+ expect(cause.get()).toBe(0);
49
+ });
50
+
51
+ test('should set new value with .set(42)', function () {
52
+ const cause = state(0);
53
+ cause.set(42);
54
+ expect(cause.get()).toBe(42);
55
+ });
56
+
57
+ test('should increment value with .set(v => ++v)', function () {
58
+ const cause = state(0);
59
+ cause.update(v => ++v);
60
+ expect(cause.get()).toBe(1);
61
+ });
62
+
63
+ });
64
+
65
+ describe('String cause', function () {
66
+
67
+ test('should be string', function () {
68
+ const cause = state('foo');
69
+ expect(typeof cause.get()).toBe('string');
70
+ });
71
+
72
+ test('should set initial value to "foo"', function () {
73
+ const cause = state('foo');
74
+ expect(cause.get()).toBe('foo');
75
+ });
76
+
77
+ test('should set new value with .set("bar")', function () {
78
+ const cause = state('foo');
79
+ cause.set('bar');
80
+ expect(cause.get()).toBe('bar');
81
+ });
82
+
83
+ test('should upper case value with .set(v => v.toUpperCase())', function () {
84
+ const cause = state('foo');
85
+ cause.update(v => v ? v.toUpperCase() : '');
86
+ expect(cause.get()).toBe("FOO");
87
+ });
88
+
89
+ });
90
+
91
+ describe('Array cause', function () {
92
+
93
+ test('should be array', function () {
94
+ const cause = state([1, 2, 3]);
95
+ expect(Array.isArray(cause.get())).toBe(true);
96
+ });
97
+
98
+ test('should set initial value to [1, 2, 3]', function () {
99
+ const cause = state([1, 2, 3]);
100
+ expect(cause.get()).toEqual([1, 2, 3]);
101
+ });
102
+
103
+ test('should set new value with .set([4, 5, 6])', function () {
104
+ const cause = state([1, 2, 3]);
105
+ cause.set([4, 5, 6]);
106
+ expect(cause.get()).toEqual([4, 5, 6]);
107
+ });
108
+
109
+ test('should reflect current value of array after modification', function () {
110
+ const array = [1, 2, 3];
111
+ const cause = state(array);
112
+ array.push(4); // don't do this! the result will be correct, but we can't trigger effects
113
+ expect(cause.get()).toEqual([1, 2, 3, 4]);
114
+ });
115
+
116
+ test('should set new value with .set([...array, 4])', function () {
117
+ const array = [1, 2, 3];
118
+ const cause = state(array);
119
+ cause.set([...array, 4]); // use destructuring instead!
120
+ expect(cause.get()).toEqual([1, 2, 3, 4]);
121
+ });
122
+
123
+ });
124
+
125
+ describe('Object cause', function () {
126
+
127
+ test('should be object', function () {
128
+ const cause = state({ a: 'a', b: 1 });
129
+ expect(typeof cause.get()).toBe('object');
130
+ });
131
+
132
+ test('should set initial value to { a: "a", b: 1 }', function () {
133
+ const cause = state({ a: 'a', b: 1 });
134
+ expect(cause.get()).toEqual({ a: 'a', b: 1 });
135
+ });
136
+
137
+ test('should set new value with .set({ c: true })', function () {
138
+ const cause = state<Record<string, any>>({ a: 'a', b: 1 });
139
+ cause.set({ c: true });
140
+ expect(cause.get()).toEqual({ c: true });
141
+ });
142
+
143
+ test('should reflect current value of object after modification', function () {
144
+ const obj = { a: 'a', b: 1 };
145
+ const cause = state<Record<string, any>>(obj);
146
+ // @ts-expect-error
147
+ obj.c = true; // don't do this! the result will be correct, but we can't trigger effects
148
+ expect(cause.get()).toEqual({ a: 'a', b: 1, c: true });
149
+ });
150
+
151
+ test('should set new value with .set({...obj, c: true})', function () {
152
+ const obj = { a: 'a', b: 1 };
153
+ const cause = state<Record<string, any>>(obj);
154
+ cause.set({...obj, c: true}); // use destructuring instead!
155
+ expect(cause.get()).toEqual({ a: 'a', b: 1, c: true });
156
+ });
157
+
158
+ });
159
+
160
+ describe('Map method', function () {
161
+
162
+ test('should return a computed signal', function() {
163
+ const cause = state(42);
164
+ const double = cause.map(v => v * 2);
165
+ expect(isComputed(double)).toBe(true);
166
+ expect(double.get()).toBe(84);
167
+ });
168
+
169
+ });
170
+
171
+ describe('Match method', function () {
172
+
173
+ test('should create an effect that reacts on signal changes', function() {
174
+ const cause = state(42);
175
+ let okCount = 0;
176
+ let nilCount = 0;
177
+ let result: number = 0;
178
+ cause.match({
179
+ ok: v => {
180
+ result = v;
181
+ okCount++
182
+ },
183
+ nil: () => {
184
+ nilCount++
185
+ }
186
+ })
187
+ cause.set(43);
188
+ expect(okCount).toBe(2); // + 1 for effect initialization
189
+ expect(nilCount).toBe(0);
190
+ expect(result).toBe(43);
191
+
192
+ cause.set(UNSET);
193
+ expect(okCount).toBe(2);
194
+ expect(nilCount).toBe(1);
195
+ });
196
+
197
+ });
198
+
199
+ });
@@ -1,4 +1,12 @@
1
- import { pseudoRandom } from './pseudo-random'
1
+ import { TestConfig } from "./framework-types";
2
+ import { Computed, ReactiveFramework, Signal } from "./reactive-framework";
3
+ import { Random } from "random";
4
+
5
+ export interface Graph {
6
+ sources: Signal<number>[];
7
+ layers: Computed<number>[][];
8
+ }
9
+
2
10
  /**
3
11
  * Make a rectangular dependency graph, with an equal number of source elements
4
12
  * and computation elements at every layer.
@@ -8,11 +16,15 @@ import { pseudoRandom } from './pseudo-random'
8
16
  * @param staticFraction every nth computed node is static (1 = all static, 3 = 2/3rd are dynamic)
9
17
  * @returns the graph
10
18
  */
11
- export function makeGraph(framework, config) {
19
+ export function makeGraph(
20
+ framework: ReactiveFramework,
21
+ config: TestConfig,
22
+ counter: Counter
23
+ ): Graph {
12
24
  const { width, totalLayers, staticFraction, nSources } = config;
25
+
13
26
  return framework.withBuild(() => {
14
27
  const sources = new Array(width).fill(0).map((_, i) => framework.signal(i));
15
- const counter = new Counter();
16
28
  const rows = makeDependentRows(
17
29
  sources,
18
30
  totalLayers - 1,
@@ -22,54 +34,95 @@ export function makeGraph(framework, config) {
22
34
  framework
23
35
  );
24
36
  const graph = { sources, layers: rows };
25
- return { graph, counter };
37
+ return graph;
26
38
  });
27
39
  }
40
+
28
41
  /**
29
42
  * Execute the graph by writing one of the sources and reading some or all of the leaves.
30
43
  *
31
44
  * @return the sum of all leaf values
32
45
  */
33
- export function runGraph(graph, iterations, readFraction, framework) {
34
- const rand = pseudoRandom();
46
+ export function runGraph(
47
+ graph: Graph,
48
+ iterations: number,
49
+ readFraction: number,
50
+ framework: ReactiveFramework
51
+ ): number {
52
+ const rand = new Random("seed");
35
53
  const { sources, layers } = graph;
36
54
  const leaves = layers[layers.length - 1];
37
55
  const skipCount = Math.round(leaves.length * (1 - readFraction));
38
56
  const readLeaves = removeElems(leaves, skipCount, rand);
39
- for (let i = 0; i < iterations; i++) {
57
+ const frameworkName = framework.name.toLowerCase();
58
+ // const start = Date.now();
59
+ let sum = 0;
60
+
61
+ if (frameworkName === "s-js" || frameworkName === "solidjs") {
62
+ // [S.js freeze](https://github.com/adamhaile/S#sdatavalue) doesn't allow different values to be set during a single batch, so special case it.
63
+ for (let i = 0; i < iterations; i++) {
64
+ framework.withBatch(() => {
65
+ const sourceDex = i % sources.length;
66
+ sources[sourceDex].write(i + sourceDex);
67
+ });
68
+
69
+ for (const leaf of readLeaves) {
70
+ leaf.read();
71
+ }
72
+ }
73
+
74
+ sum = readLeaves.reduce((total, leaf) => leaf.read() + total, 0);
75
+ } else {
40
76
  framework.withBatch(() => {
41
- const sourceDex = i % sources.length;
42
- sources[sourceDex].write(i + sourceDex);
77
+ for (let i = 0; i < iterations; i++) {
78
+ // Useful for debugging edge cases for some frameworks that experience
79
+ // dramatic slow downs for certain test configurations. These are generally
80
+ // due to `computed` effects not being cached efficiently, and as the number
81
+ // of layers increases, the uncached `computed` effects are re-evaluated in
82
+ // an `O(n^2)` manner where `n` is the number of layers.
83
+ /* if (i % 100 === 0) {
84
+ console.log("iteration:", i, "delta:", Date.now() - start);
85
+ } */
86
+
87
+ const sourceDex = i % sources.length;
88
+ sources[sourceDex].write(i + sourceDex);
89
+
90
+ for (const leaf of readLeaves) {
91
+ leaf.read();
92
+ }
93
+ }
94
+
95
+ sum = readLeaves.reduce((total, leaf) => leaf.read() + total, 0);
43
96
  });
44
- for (const leaf of readLeaves) {
45
- leaf.read();
46
- }
47
97
  }
48
- const sum = readLeaves.reduce((total, leaf) => leaf.read() + total, 0);
98
+
49
99
  return sum;
50
100
  }
51
- function removeElems(src, rmCount, rand) {
101
+
102
+ function removeElems<T>(src: T[], rmCount: number, rand: Random): T[] {
52
103
  const copy = src.slice();
53
104
  for (let i = 0; i < rmCount; i++) {
54
- const rmDex = Math.floor(rand() * copy.length);
105
+ const rmDex = rand.int(0, copy.length - 1);
55
106
  copy.splice(rmDex, 1);
56
107
  }
57
108
  return copy;
58
109
  }
110
+
59
111
  export class Counter {
60
- count = 0;
112
+ count = 0;
61
113
  }
114
+
62
115
  function makeDependentRows(
63
- sources,
64
- numRows,
65
- counter,
66
- staticFraction,
67
- nSources,
68
- framework
69
- ): { read: number }[][] {
116
+ sources: Computed<number>[],
117
+ numRows: number,
118
+ counter: Counter,
119
+ staticFraction: number,
120
+ nSources: number,
121
+ framework: ReactiveFramework
122
+ ): Computed<number>[][] {
70
123
  let prevRow = sources;
71
- const random = pseudoRandom();
72
- const rows: {read: number}[][] = [];
124
+ const rand = new Random("seed");
125
+ const rows = [];
73
126
  for (let l = 0; l < numRows; l++) {
74
127
  const row = makeRow(
75
128
  prevRow,
@@ -78,32 +131,35 @@ function makeDependentRows(
78
131
  nSources,
79
132
  framework,
80
133
  l,
81
- random
134
+ rand
82
135
  );
83
- rows.push(row);
136
+ rows.push(row as never);
84
137
  prevRow = row;
85
138
  }
86
139
  return rows;
87
140
  }
141
+
88
142
  function makeRow(
89
- sources,
90
- counter,
91
- staticFraction,
92
- nSources,
93
- framework,
94
- layer,
95
- random
96
- ) {
143
+ sources: Computed<number>[],
144
+ counter: Counter,
145
+ staticFraction: number,
146
+ nSources: number,
147
+ framework: ReactiveFramework,
148
+ _layer: number,
149
+ random: Random
150
+ ): Computed<number>[] {
97
151
  return sources.map((_, myDex) => {
98
- const mySources: { read(): number }[] = [];
152
+ const mySources: Computed<number>[] = [];
99
153
  for (let sourceDex = 0; sourceDex < nSources; sourceDex++) {
100
154
  mySources.push(sources[(myDex + sourceDex) % sources.length]);
101
155
  }
102
- const staticNode = random() < staticFraction;
156
+
157
+ const staticNode = random.float() < staticFraction;
103
158
  if (staticNode) {
104
159
  // static node, always reference sources
105
160
  return framework.computed(() => {
106
161
  counter.count++;
162
+
107
163
  let sum = 0;
108
164
  for (const src of mySources) {
109
165
  sum += src.read();
@@ -119,10 +175,12 @@ function makeRow(
119
175
  let sum = first.read();
120
176
  const shouldDrop = sum & 0x1;
121
177
  const dropDex = sum % tail.length;
178
+
122
179
  for (let i = 0; i < tail.length; i++) {
123
180
  if (shouldDrop && i === dropDex) continue;
124
181
  sum += tail[i].read();
125
182
  }
183
+
126
184
  return sum;
127
185
  });
128
186
  return node;
@@ -0,0 +1,53 @@
1
+ import { TestResult } from "./perf-tests";
2
+ import { ReactiveFramework } from "./reactive-framework";
3
+
4
+ /** Parameters for a running a performance benchmark test
5
+ *
6
+ * The benchmarks create a rectangular grid of reactive elements, with
7
+ * mutable signals in the first level, computed elements in the middle levels,
8
+ * and read effect elements in the last level.
9
+ *
10
+ * Each test iteration modifies one signal, and then reads specified
11
+ * fraction of the effect elements.
12
+ *
13
+ * Each non-signal node sums values from a specified number of elements
14
+ * in the preceding layer. Some nodes are dynamic, and read vary
15
+ * the number of sources the read for the sum.
16
+ *
17
+ * Tests may optionally provide result values to verify the sum
18
+ * of all read effect elements in all iterations, and the total
19
+ * number of non-signal updated.
20
+ */
21
+ export interface TestConfig {
22
+ /** friendly name for the test, should be unique */
23
+ name?: string;
24
+
25
+ /** width of dependency graph to construct */
26
+ width: number;
27
+
28
+ /** depth of dependency graph to construct */
29
+ totalLayers: number;
30
+
31
+ /** fraction of nodes that are static */ // TODO change to dynamicFraction
32
+ staticFraction: number;
33
+
34
+ /** construct a graph with number of sources in each node */
35
+ nSources: number;
36
+
37
+ /** fraction of [0, 1] elements in the last layer from which to read values in each test iteration */
38
+ readFraction: number;
39
+
40
+ /** number of test iterations */
41
+ iterations: number;
42
+
43
+ /** sum and count of all iterations, for verification */
44
+ expected: Partial<TestResult>;
45
+ }
46
+
47
+ export interface FrameworkInfo {
48
+ /** wrapper/adapter for a benchmarking a reactive framework */
49
+ framework: ReactiveFramework;
50
+
51
+ /** verify the number of nodes executed matches the expected number */
52
+ testPullCounts?: boolean;
53
+ }
@@ -0,0 +1,42 @@
1
+ import { FrameworkInfo, TestConfig } from "./framework-types";
2
+
3
+ export interface TestResult {
4
+ sum: number;
5
+ count: number;
6
+ }
7
+
8
+ export interface TimingResult<T> {
9
+ result: T;
10
+ timing: TestTiming;
11
+ }
12
+
13
+ export interface TestTiming {
14
+ time: number;
15
+ }
16
+
17
+ export function verifyBenchResult(
18
+ perfFramework: FrameworkInfo,
19
+ config: TestConfig,
20
+ timedResult: TimingResult<TestResult>
21
+ ): void {
22
+ const { testPullCounts, framework } = perfFramework;
23
+ const { expected } = config;
24
+ const { result } = timedResult;
25
+
26
+ if (expected.sum) {
27
+ console.assert(
28
+ result.sum == expected.sum,
29
+ `sum ${framework.name} ${config.name} result:${result.sum} expected:${expected.sum}`
30
+ );
31
+ }
32
+ if (
33
+ expected.count &&
34
+ (config.readFraction === 1 || testPullCounts) &&
35
+ testPullCounts !== false
36
+ ) {
37
+ console.assert(
38
+ result.count === expected.count,
39
+ `count ${framework.name} ${config.name} result:${result.count} expected:${expected.count}`
40
+ );
41
+ }
42
+ }
@@ -0,0 +1,22 @@
1
+ /** interface for a reactive framework.
2
+ *
3
+ * Implement this interface to add a new reactive framework to the test and performance test suite.
4
+ */
5
+ export interface ReactiveFramework {
6
+ name: string;
7
+ signal<T>(initialValue: T): Signal<T>;
8
+ computed<T>(fn: () => T): Computed<T>;
9
+ effect(fn: () => void): void;
10
+ withBatch<T>(fn: () => T): void;
11
+ withBuild<T>(fn: () => T): T;
12
+ }
13
+
14
+ export interface Signal<T> {
15
+ read(): T;
16
+ write(v: T): void;
17
+ }
18
+
19
+ export interface Computed<T> {
20
+ read(): T;
21
+ }
22
+