@zeix/cause-effect 0.9.7

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,467 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { state, computed, isComputed, effect, batch } from '../index'
3
+
4
+ /* === Utility Functions === */
5
+
6
+ const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
7
+ const increment = (n: number | void) => (n ?? 0) + 1;
8
+ const decrement = (n: number | void) => (n ?? 0) - 1;
9
+
10
+ /* === Tests === */
11
+
12
+ describe('State', function () {
13
+
14
+ describe('Empty cause', function () {
15
+
16
+ test('should be undefined', function () {
17
+ const cause = state(undefined);
18
+ expect(cause.get()).toBeUndefined();
19
+ });
20
+
21
+ });
22
+
23
+ describe('Boolean cause', function () {
24
+
25
+ test('should be boolean', function () {
26
+ const cause = state(false);
27
+ expect(typeof cause.get()).toBe('boolean');
28
+ });
29
+
30
+ test('should set initial value to false', function () {
31
+ const cause = state(false);
32
+ expect(cause.get()).toBe(false);
33
+ });
34
+
35
+ test('should set initial value to true', function () {
36
+ const cause = state(true);
37
+ expect(cause.get()).toBe(true);
38
+ });
39
+
40
+ test('should set new value with .set(true)', function () {
41
+ const cause = state(false);
42
+ cause.set(true);
43
+ expect(cause.get()).toBe(true);
44
+ });
45
+
46
+ test('should toggle initial value with .set(v => !v)', function () {
47
+ const cause = state(false);
48
+ cause.set((v) => !v);
49
+ expect(cause.get()).toBe(true);
50
+ });
51
+
52
+ });
53
+
54
+ describe('Number cause', function () {
55
+
56
+ test('should be number', function () {
57
+ const cause = state(0);
58
+ expect(typeof cause.get()).toBe('number');
59
+ });
60
+
61
+ test('should set initial value to 0', function () {
62
+ const cause = state(0);
63
+ expect(cause.get()).toBe(0);
64
+ });
65
+
66
+ test('should set new value with .set(42)', function () {
67
+ const cause = state(0);
68
+ cause.set(42);
69
+ expect(cause.get()).toBe(42);
70
+ });
71
+
72
+ test('should increment value with .set(v => ++v)', function () {
73
+ const cause = state(0);
74
+ cause.set(v => ++v);
75
+ expect(cause.get()).toBe(1);
76
+ });
77
+
78
+ });
79
+
80
+ describe('String cause', function () {
81
+
82
+ test('should be string', function () {
83
+ const cause = state('foo');
84
+ expect(typeof cause.get()).toBe('string');
85
+ });
86
+
87
+ test('should set initial value to "foo"', function () {
88
+ const cause = state('foo');
89
+ expect(cause.get()).toBe('foo');
90
+ });
91
+
92
+ test('should set new value with .set("bar")', function () {
93
+ const cause = state('foo');
94
+ cause.set('bar');
95
+ expect(cause.get()).toBe('bar');
96
+ });
97
+
98
+ test('should upper case value with .set(v => v.toUpperCase())', function () {
99
+ const cause = state('foo');
100
+ cause.set(v => v ? v.toUpperCase() : '');
101
+ expect(cause.get()).toBe("FOO");
102
+ });
103
+
104
+ });
105
+
106
+ describe('Array cause', function () {
107
+
108
+ test('should be array', function () {
109
+ const cause = state([1, 2, 3]);
110
+ expect(Array.isArray(cause.get())).toBe(true);
111
+ });
112
+
113
+ test('should set initial value to [1, 2, 3]', function () {
114
+ const cause = state([1, 2, 3]);
115
+ expect(cause.get()).toEqual([1, 2, 3]);
116
+ });
117
+
118
+ test('should set new value with .set([4, 5, 6])', function () {
119
+ const cause = state([1, 2, 3]);
120
+ cause.set([4, 5, 6]);
121
+ expect(cause.get()).toEqual([4, 5, 6]);
122
+ });
123
+
124
+ test('should reflect current value of array after modification', function () {
125
+ const array = [1, 2, 3];
126
+ const cause = state(array);
127
+ array.push(4); // don't do this! the result will be correct, but we can't trigger effects
128
+ expect(cause.get()).toEqual([1, 2, 3, 4]);
129
+ });
130
+
131
+ test('should set new value with .set([...array, 4])', function () {
132
+ const array = [1, 2, 3];
133
+ const cause = state(array);
134
+ cause.set([...array, 4]); // use destructuring instead!
135
+ expect(cause.get()).toEqual([1, 2, 3, 4]);
136
+ });
137
+
138
+ });
139
+
140
+ describe('Object cause', function () {
141
+
142
+ test('should be object', function () {
143
+ const cause = state({ a: 'a', b: 1 });
144
+ expect(typeof cause.get()).toBe('object');
145
+ });
146
+
147
+ test('should set initial value to { a: "a", b: 1 }', function () {
148
+ const cause = state({ a: 'a', b: 1 });
149
+ expect(cause.get()).toEqual({ a: 'a', b: 1 });
150
+ });
151
+
152
+ test('should set new value with .set({ c: true })', function () {
153
+ const cause = state<Record<string, any>>({ a: 'a', b: 1 });
154
+ cause.set({ c: true });
155
+ expect(cause.get()).toEqual({ c: true });
156
+ });
157
+
158
+ test('should reflect current value of object after modification', function () {
159
+ const obj = { a: 'a', b: 1 };
160
+ const cause = state<Record<string, any>>(obj);
161
+ // @ts-expect-error
162
+ obj.c = true; // don't do this! the result will be correct, but we can't trigger effects
163
+ expect(cause.get()).toEqual({ a: 'a', b: 1, c: true });
164
+ });
165
+
166
+ test('should set new value with .set({...obj, c: true})', function () {
167
+ const obj = { a: 'a', b: 1 };
168
+ const cause = state<Record<string, any>>(obj);
169
+ cause.set({...obj, c: true}); // use destructuring instead!
170
+ expect(cause.get()).toEqual({ a: 'a', b: 1, c: true });
171
+ });
172
+
173
+ });
174
+
175
+ describe('Map method', function () {
176
+
177
+ test('should return a computed signal', function() {
178
+ const cause = state(42);
179
+ const double = cause.map(v => v * 2);
180
+ expect(isComputed(double)).toBe(true);
181
+ expect(double.get()).toBe(84);
182
+ });
183
+
184
+ });
185
+
186
+ });
187
+
188
+ describe('Computed', function () {
189
+
190
+ test('should compute a function', function() {
191
+ const derived = computed(() => 1 + 2);
192
+ expect(derived.get()).toBe(3);
193
+ });
194
+
195
+ test('should compute function dependent on a signal', function() {
196
+ const derived = state(42).map(v => ++v);
197
+ expect(derived.get()).toBe(43);
198
+ });
199
+
200
+ test('should compute function dependent on an updated signal', function() {
201
+ const cause = state(42);
202
+ const derived = cause.map(v => ++v);
203
+ cause.set(24);
204
+ expect(derived.get()).toBe(25);
205
+ });
206
+
207
+ test('should compute function dependent on an async signal', async function() {
208
+ const status = state('pending');
209
+ const promised = computed<number>(async () => {
210
+ await wait(100);
211
+ status.set('success');
212
+ return 42;
213
+ });
214
+ const derived = promised.map(increment);
215
+ expect(derived.get()).toBe(1);
216
+ expect(status.get()).toBe('pending');
217
+ await wait(100);
218
+ expect(derived.get()).toBe(43);
219
+ expect(status.get()).toBe('success');
220
+ });
221
+
222
+ test('should handle errors from an async signal gracefully', async function() {
223
+ const status = state('pending');
224
+ const error = state('');
225
+ const promised = computed(async () => {
226
+ await wait(100);
227
+ status.set('error');
228
+ error.set('error occurred');
229
+ return 0
230
+ });
231
+ const derived = promised.map(increment);
232
+ expect(derived.get()).toBe(1);
233
+ expect(status.get()).toBe('pending');
234
+ await wait(100);
235
+ expect(error.get()).toBe('error occurred');
236
+ expect(status.get()).toBe('error');
237
+ });
238
+
239
+ test('should compute function dependent on a chain of computed states dependent on a signal', function() {
240
+ const derived = state(42)
241
+ .map(v => ++v)
242
+ .map(v => v * 2)
243
+ .map(v => ++v);
244
+ expect(derived.get()).toBe(87);
245
+ });
246
+
247
+ test('should compute function dependent on a chain of computed states dependent on an updated signal', function() {
248
+ const cause = state(42);
249
+ const derived = cause.map(v => ++v)
250
+ .map(v => v * 2)
251
+ .map(v => ++v);
252
+ cause.set(24);
253
+ expect(derived.get()).toBe(51);
254
+ });
255
+
256
+ test('should drop X->B->X updates', function () {
257
+ let count = 0;
258
+ const x = state(2);
259
+ const a = x.map(decrement);
260
+ const b = computed(() => x.get() + (a.get() ?? 0));
261
+ const c = computed(() => {
262
+ count++;
263
+ return 'c: ' + b.get();
264
+ });
265
+ expect(c.get()).toBe('c: 3');
266
+ expect(count).toBe(1);
267
+ x.set(4);
268
+ expect(c.get()).toBe('c: 7');
269
+ expect(count).toBe(2);
270
+ });
271
+
272
+ test('should only update every signal once (diamond graph)', function() {
273
+ let count = 0;
274
+ const x = state('a');
275
+ const a = x.map(v => v);
276
+ const b = x.map(v => v);
277
+ const c = computed(() => {
278
+ count++;
279
+ return a.get() + ' ' + b.get();
280
+ });
281
+ expect(c.get()).toBe('a a');
282
+ expect(count).toBe(1);
283
+ x.set('aa');
284
+ expect(c.get()).toBe('aa aa');
285
+ expect(count).toBe(2);
286
+ });
287
+
288
+ test('should only update every signal once (diamond graph + tail)', function() {
289
+ let count = 0;
290
+ const x = state('a');
291
+ const a = x.map(v => v);
292
+ const b = x.map(v => v);
293
+ const c = computed(() => a.get() + ' ' + b.get());
294
+ const d = computed(() => {
295
+ count++;
296
+ return c.get();
297
+ });
298
+ expect(d.get()).toBe('a a');
299
+ expect(count).toBe(1);
300
+ x.set('aa');
301
+ expect(d.get()).toBe('aa aa');
302
+ expect(count).toBe(2);
303
+ });
304
+
305
+ test('should bail out if result is the same', function() {
306
+ let count = 0;
307
+ const x = state('a');
308
+ const a = computed(() => {
309
+ x.get();
310
+ return 'foo';
311
+ });
312
+ const b = computed(() => {
313
+ count++;
314
+ return a.get();
315
+ }, true); // turn memoization on
316
+ expect(b.get()).toBe('foo');
317
+ expect(count).toBe(1);
318
+ x.set('aa');
319
+ expect(b.get()).toBe('foo');
320
+ expect(count).toBe(1);
321
+ });
322
+
323
+ test('should block if result remains unchanged', function() {
324
+ let count = 0;
325
+ const x = state(42);
326
+ const a = x.map(v => v % 2);
327
+ const b = computed(() => a.get() ? 'odd' : 'even', true);
328
+ const c = computed(() => {
329
+ count++;
330
+ return `c: ${b.get()}`;
331
+ }, true);
332
+ expect(c.get()).toBe('c: even');
333
+ expect(count).toBe(1);
334
+ x.set(44);
335
+ expect(c.get()).toBe('c: even');
336
+ expect(count).toBe(1);
337
+ });
338
+
339
+ test('should block if an error occurred', function() {
340
+ let count = 0;
341
+ const x = state(0);
342
+ const a = computed(() => {
343
+ if (x.get() === 1) throw new Error('Calculation error');
344
+ return 1;
345
+ }, true);
346
+ const b = a.map(v => v ? 'success' : 'pending');
347
+ const c = computed(() => {
348
+ count++;
349
+ return `c: ${b.get()}`;
350
+ }, true);
351
+ expect(a.get()).toBe(1);
352
+ expect(c.get()).toBe('c: success');
353
+ expect(count).toBe(1);
354
+ x.set(1);
355
+ try {
356
+ expect(a.get()).toBe(1);
357
+ } catch (error) {
358
+ expect(error.message).toBe('Calculation error');
359
+ } finally {
360
+ expect(c.get()).toBe('c: success');
361
+ expect(count).toBe(1);
362
+ }
363
+ });
364
+
365
+ });
366
+
367
+ describe('Effect', function () {
368
+
369
+ /* test('should be added to state.effects', function () {
370
+ const cause = state();
371
+ effect(() => state());
372
+ expect(state.effects.size).toBe(1);
373
+ effect(() => state());
374
+ expect(state.effects.size).toBe(2);
375
+ });
376
+
377
+ test('should be added to computed.effects', function () {
378
+ const cause = state();
379
+ const derived = computed(() => 1 + state());
380
+ effect(() => computed());
381
+ expect(computed.effects.size).toBe(1);
382
+ const derived2 = computed(() => 2 + state());
383
+ effect(() => computed() + computed2());
384
+ expect(computed.effects.size).toBe(2);
385
+ expect(computed2.effects.size).toBe(1);
386
+ }); */
387
+
388
+ test('should be triggered after a state change', function() {
389
+ const cause = state('foo');
390
+ let effectDidRun = false;
391
+ effect(() => {
392
+ cause.get();
393
+ effectDidRun = true;
394
+ });
395
+ cause.set('bar');
396
+ expect(effectDidRun).toBe(true);
397
+ });
398
+
399
+ test('should be triggered repeatedly after repeated state change', async function() {
400
+ const cause = state(0);
401
+ let count = 0;
402
+ effect(() => {
403
+ cause.get();
404
+ count++;
405
+ });
406
+ for (let i = 0; i < 10; i++) {
407
+ cause.set(i);
408
+ expect(count).toBe(i + 1); // + 1 for the initial state change
409
+ }
410
+ });
411
+
412
+ test('should update multiple times after multiple state changes', function() {
413
+ const a = state(3);
414
+ const b = state(4);
415
+ let count = 0;
416
+ const sum = computed(() => {
417
+ count++;
418
+ return a.get() + b.get()
419
+ });
420
+ expect(sum.get()).toBe(7);
421
+ a.set(6);
422
+ expect(sum.get()).toBe(10);
423
+ b.set(8);
424
+ expect(sum.get()).toBe(14);
425
+ expect(count).toBe(3);
426
+ });
427
+
428
+ });
429
+
430
+ describe('Batch', function () {
431
+
432
+ test('should be triggered only once after repeated state change', function() {
433
+ const cause = state(0);
434
+ let result = 0;
435
+ let count = 0;
436
+ batch(() => {
437
+ for (let i = 1; i <= 10; i++) {
438
+ cause.set(i);
439
+ }
440
+ });
441
+ effect(() => {
442
+ result = cause.get();
443
+ count++;
444
+ });
445
+ expect(result).toBe(10);
446
+ expect(count).toBe(1);
447
+ });
448
+
449
+ test('should be triggered only once when multiple signals are set', function() {
450
+ const a = state(3);
451
+ const b = state(4);
452
+ const sum = computed(() => a.get() + b.get());
453
+ let result = 0;
454
+ let count = 0;
455
+ batch(() => {
456
+ a.set(6);
457
+ b.set(8);
458
+ });
459
+ effect(() => {
460
+ result = sum.get();
461
+ count++;
462
+ });
463
+ expect(sum.get()).toBe(14);
464
+ expect(count).toBe(1);
465
+ });
466
+
467
+ });
@@ -0,0 +1,131 @@
1
+ import { pseudoRandom } from './pseudo-random'
2
+ /**
3
+ * Make a rectangular dependency graph, with an equal number of source elements
4
+ * and computation elements at every layer.
5
+ *
6
+ * @param width number of source elements and number of computed elements per layer
7
+ * @param totalLayers total number of source and computed layers
8
+ * @param staticFraction every nth computed node is static (1 = all static, 3 = 2/3rd are dynamic)
9
+ * @returns the graph
10
+ */
11
+ export function makeGraph(framework, config) {
12
+ const { width, totalLayers, staticFraction, nSources } = config;
13
+ return framework.withBuild(() => {
14
+ const sources = new Array(width).fill(0).map((_, i) => framework.signal(i));
15
+ const counter = new Counter();
16
+ const rows = makeDependentRows(
17
+ sources,
18
+ totalLayers - 1,
19
+ counter,
20
+ staticFraction,
21
+ nSources,
22
+ framework
23
+ );
24
+ const graph = { sources, layers: rows };
25
+ return { graph, counter };
26
+ });
27
+ }
28
+ /**
29
+ * Execute the graph by writing one of the sources and reading some or all of the leaves.
30
+ *
31
+ * @return the sum of all leaf values
32
+ */
33
+ export function runGraph(graph, iterations, readFraction, framework) {
34
+ const rand = pseudoRandom();
35
+ const { sources, layers } = graph;
36
+ const leaves = layers[layers.length - 1];
37
+ const skipCount = Math.round(leaves.length * (1 - readFraction));
38
+ const readLeaves = removeElems(leaves, skipCount, rand);
39
+ for (let i = 0; i < iterations; i++) {
40
+ framework.withBatch(() => {
41
+ const sourceDex = i % sources.length;
42
+ sources[sourceDex].write(i + sourceDex);
43
+ });
44
+ for (const leaf of readLeaves) {
45
+ leaf.read();
46
+ }
47
+ }
48
+ const sum = readLeaves.reduce((total, leaf) => leaf.read() + total, 0);
49
+ return sum;
50
+ }
51
+ function removeElems(src, rmCount, rand) {
52
+ const copy = src.slice();
53
+ for (let i = 0; i < rmCount; i++) {
54
+ const rmDex = Math.floor(rand() * copy.length);
55
+ copy.splice(rmDex, 1);
56
+ }
57
+ return copy;
58
+ }
59
+ export class Counter {
60
+ count = 0;
61
+ }
62
+ function makeDependentRows(
63
+ sources,
64
+ numRows,
65
+ counter,
66
+ staticFraction,
67
+ nSources,
68
+ framework
69
+ ): { read: number }[][] {
70
+ let prevRow = sources;
71
+ const random = pseudoRandom();
72
+ const rows: {read: number}[][] = [];
73
+ for (let l = 0; l < numRows; l++) {
74
+ const row = makeRow(
75
+ prevRow,
76
+ counter,
77
+ staticFraction,
78
+ nSources,
79
+ framework,
80
+ l,
81
+ random
82
+ );
83
+ rows.push(row);
84
+ prevRow = row;
85
+ }
86
+ return rows;
87
+ }
88
+ function makeRow(
89
+ sources,
90
+ counter,
91
+ staticFraction,
92
+ nSources,
93
+ framework,
94
+ layer,
95
+ random
96
+ ) {
97
+ return sources.map((_, myDex) => {
98
+ const mySources: { read(): number }[] = [];
99
+ for (let sourceDex = 0; sourceDex < nSources; sourceDex++) {
100
+ mySources.push(sources[(myDex + sourceDex) % sources.length]);
101
+ }
102
+ const staticNode = random() < staticFraction;
103
+ if (staticNode) {
104
+ // static node, always reference sources
105
+ return framework.computed(() => {
106
+ counter.count++;
107
+ let sum = 0;
108
+ for (const src of mySources) {
109
+ sum += src.read();
110
+ }
111
+ return sum;
112
+ });
113
+ } else {
114
+ // dynamic node, drops one of the sources depending on the value of the first element
115
+ const first = mySources[0];
116
+ const tail = mySources.slice(1);
117
+ const node = framework.computed(() => {
118
+ counter.count++;
119
+ let sum = first.read();
120
+ const shouldDrop = sum & 0x1;
121
+ const dropDex = sum % tail.length;
122
+ for (let i = 0; i < tail.length; i++) {
123
+ if (shouldDrop && i === dropDex) continue;
124
+ sum += tail[i].read();
125
+ }
126
+ return sum;
127
+ });
128
+ return node;
129
+ }
130
+ });
131
+ }
@@ -0,0 +1,45 @@
1
+ export function pseudoRandom(seed = "seed") {
2
+ const hash = xmur3a(seed);
3
+ const rng = sfc32(hash(), hash(), hash(), hash());
4
+ return rng;
5
+ }
6
+ /* these are adapted from https://github.com/bryc/code/blob/master/jshash/PRNGs.md
7
+ * (License: Public domain) */
8
+ /** random number generator originally in PractRand */
9
+ function sfc32(a, b, c, d) {
10
+ return function () {
11
+ a >>>= 0;
12
+ b >>>= 0;
13
+ c >>>= 0;
14
+ d >>>= 0;
15
+ let t = (a + b) | 0;
16
+ a = b ^ (b >>> 9);
17
+ b = (c + (c << 3)) | 0;
18
+ c = (c << 21) | (c >>> 11);
19
+ d = (d + 1) | 0;
20
+ t = (t + d) | 0;
21
+ c = (c + t) | 0;
22
+ return (t >>> 0) / 4294967296;
23
+ };
24
+ }
25
+ /** MurmurHash3 */
26
+ export function xmur3a(str) {
27
+ let h = 2166136261 >>> 0;
28
+ for (let k, i = 0; i < str.length; i++) {
29
+ k = Math.imul(str.charCodeAt(i), 3432918353);
30
+ k = (k << 15) | (k >>> 17);
31
+ h ^= Math.imul(k, 461845907);
32
+ h = (h << 13) | (h >>> 19);
33
+ h = (Math.imul(h, 5) + 3864292196) | 0;
34
+ }
35
+ h ^= str.length;
36
+ return function () {
37
+ h ^= h >>> 16;
38
+ h = Math.imul(h, 2246822507);
39
+ h ^= h >>> 13;
40
+ h = Math.imul(h, 3266489909);
41
+ h ^= h >>> 16;
42
+ return h >>> 0;
43
+ };
44
+ }
45
+
package/tsconfig.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Enable latest features
4
+ "lib": ["ESNext", "DOM"],
5
+ "target": "ESNext",
6
+ "module": "ESNext",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+
16
+ // Best practices
17
+ "strict": true,
18
+ "skipLibCheck": true,
19
+ "noFallthroughCasesInSwitch": true,
20
+
21
+ // Some stricter flags (disabled by default)
22
+ "noUnusedLocals": false,
23
+ "noUnusedParameters": false,
24
+ "noPropertyAccessFromIndexSignature": false,
25
+
26
+ // Declarations
27
+ "declaration": true,
28
+ "declarationDir": "./",
29
+ "emitDeclarationOnly": true,
30
+ },
31
+ "include": ["./*.ts", "./lib/*.ts"],
32
+ "exclude": ["node_modules", "test"],
33
+ }
34
+