@zeix/cause-effect 0.18.0 → 0.18.2

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/test/list.test.ts CHANGED
@@ -3,10 +3,18 @@ import {
3
3
  createEffect,
4
4
  createList,
5
5
  createMemo,
6
+ createScope,
7
+ createState,
8
+ createTask,
6
9
  isList,
7
10
  isMemo,
11
+ match,
8
12
  } from '../index.ts'
9
13
 
14
+ /* === Utility Functions === */
15
+
16
+ const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
17
+
10
18
  describe('List', () => {
11
19
  describe('createList', () => {
12
20
  test('should return initial values from get()', () => {
@@ -437,6 +445,190 @@ describe('List', () => {
437
445
  expect(watchedCalled).toBe(true)
438
446
  dispose()
439
447
  })
448
+
449
+ test('should activate watched via sync deriveCollection', () => {
450
+ let watchedCalled = false
451
+ let unwatchedCalled = false
452
+ const list = createList([1, 2, 3], {
453
+ watched: () => {
454
+ watchedCalled = true
455
+ return () => {
456
+ unwatchedCalled = true
457
+ }
458
+ },
459
+ })
460
+
461
+ const derived = list.deriveCollection((v: number) => v * 2)
462
+
463
+ expect(watchedCalled).toBe(false)
464
+
465
+ const dispose = createEffect(() => {
466
+ derived.get()
467
+ })
468
+
469
+ expect(watchedCalled).toBe(true)
470
+ expect(unwatchedCalled).toBe(false)
471
+
472
+ dispose()
473
+ expect(unwatchedCalled).toBe(true)
474
+ })
475
+
476
+ test('should activate watched via async deriveCollection', async () => {
477
+ let watchedCalled = false
478
+ let unwatchedCalled = false
479
+ const list = createList([1, 2, 3], {
480
+ watched: () => {
481
+ watchedCalled = true
482
+ return () => {
483
+ unwatchedCalled = true
484
+ }
485
+ },
486
+ })
487
+
488
+ const derived = list.deriveCollection(
489
+ async (v: number, _abort: AbortSignal) => v * 2,
490
+ )
491
+
492
+ expect(watchedCalled).toBe(false)
493
+
494
+ const dispose = createEffect(() => {
495
+ derived.get()
496
+ })
497
+
498
+ expect(watchedCalled).toBe(true)
499
+
500
+ await wait(10)
501
+
502
+ expect(unwatchedCalled).toBe(false)
503
+
504
+ dispose()
505
+ expect(unwatchedCalled).toBe(true)
506
+ })
507
+
508
+ test('should not tear down watched during list mutation via deriveCollection', () => {
509
+ let activations = 0
510
+ let deactivations = 0
511
+ const list = createList([1, 2], {
512
+ watched: () => {
513
+ activations++
514
+ return () => {
515
+ deactivations++
516
+ }
517
+ },
518
+ })
519
+
520
+ const derived = list.deriveCollection((v: number) => v * 2)
521
+
522
+ let result: number[] = []
523
+ const dispose = createEffect(() => {
524
+ result = derived.get()
525
+ })
526
+
527
+ expect(activations).toBe(1)
528
+ expect(deactivations).toBe(0)
529
+ expect(result).toEqual([2, 4])
530
+
531
+ // Add item — should NOT tear down and restart watched
532
+ list.add(3)
533
+ expect(result).toEqual([2, 4, 6])
534
+ expect(activations).toBe(1)
535
+ expect(deactivations).toBe(0)
536
+
537
+ // Remove item — should NOT tear down and restart watched
538
+ list.remove(0)
539
+ expect(activations).toBe(1)
540
+ expect(deactivations).toBe(0)
541
+
542
+ dispose()
543
+ expect(deactivations).toBe(1)
544
+ })
545
+
546
+ test('should delay watched activation for conditional reads', () => {
547
+ let watchedCalled = false
548
+ const list = createList([1, 2], {
549
+ watched: () => {
550
+ watchedCalled = true
551
+ return () => {}
552
+ },
553
+ })
554
+
555
+ const show = createState(false)
556
+
557
+ const dispose = createScope(() => {
558
+ createEffect(() => {
559
+ if (show.get()) {
560
+ list.get()
561
+ }
562
+ })
563
+ })
564
+
565
+ // Conditional read — list not accessed, watched should not fire
566
+ expect(watchedCalled).toBe(false)
567
+
568
+ // Flip condition — list is now accessed
569
+ show.set(true)
570
+ expect(watchedCalled).toBe(true)
571
+
572
+ dispose()
573
+ })
574
+
575
+ test('should activate watched via chained deriveCollection', () => {
576
+ let watchedCalled = false
577
+ const list = createList([1, 2, 3], {
578
+ watched: () => {
579
+ watchedCalled = true
580
+ return () => {}
581
+ },
582
+ })
583
+
584
+ const doubled = list.deriveCollection((v: number) => v * 2)
585
+ const quadrupled = doubled.deriveCollection((v: number) => v * 2)
586
+
587
+ expect(watchedCalled).toBe(false)
588
+
589
+ const dispose = createEffect(() => {
590
+ quadrupled.get()
591
+ })
592
+
593
+ expect(watchedCalled).toBe(true)
594
+ dispose()
595
+ })
596
+
597
+ test('should activate watched via deriveCollection read inside match()', async () => {
598
+ let watchedCalled = false
599
+ const list = createList([1, 2], {
600
+ watched: () => {
601
+ watchedCalled = true
602
+ return () => {}
603
+ },
604
+ })
605
+
606
+ const derived = list.deriveCollection((v: number) => v * 10)
607
+
608
+ const task = createTask(async () => {
609
+ await wait(10)
610
+ return 'done'
611
+ })
612
+
613
+ const dispose = createScope(() => {
614
+ createEffect(() => {
615
+ // Read derived BEFORE match to ensure subscription
616
+ const values = derived.get()
617
+ match([task], {
618
+ ok: () => {
619
+ void values
620
+ },
621
+ nil: () => {},
622
+ })
623
+ })
624
+ })
625
+
626
+ // watched should activate synchronously even though task is pending
627
+ expect(watchedCalled).toBe(true)
628
+
629
+ await wait(50)
630
+ dispose()
631
+ })
440
632
  })
441
633
 
442
634
  describe('Input Validation', () => {
package/test/memo.test.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import {
3
+ batch,
4
+ createEffect,
3
5
  createMemo,
6
+ createScope,
4
7
  createState,
5
8
  isMemo,
6
9
  isState,
@@ -377,4 +380,195 @@ describe('Memo', () => {
377
380
  }).toThrow('[Memo] Signal value cannot be null or undefined')
378
381
  })
379
382
  })
383
+
384
+ describe('options.watched', () => {
385
+ test('should call watched on first effect access', () => {
386
+ let watchedCount = 0
387
+ const externalValue = 1
388
+
389
+ const memo = createMemo(() => externalValue, {
390
+ value: 0,
391
+ watched: _invalidate => {
392
+ watchedCount++
393
+ return () => {}
394
+ },
395
+ })
396
+
397
+ expect(watchedCount).toBe(0)
398
+
399
+ const dispose = createScope(() => {
400
+ createEffect(() => {
401
+ void memo.get()
402
+ })
403
+ })
404
+
405
+ expect(watchedCount).toBe(1)
406
+ dispose()
407
+ })
408
+
409
+ test('should call cleanup when last effect stops watching', () => {
410
+ let cleanedUp = false
411
+ const externalValue = 1
412
+
413
+ const memo = createMemo(() => externalValue, {
414
+ value: 0,
415
+ watched: _invalidate => {
416
+ return () => {
417
+ cleanedUp = true
418
+ }
419
+ },
420
+ })
421
+
422
+ const dispose = createScope(() => {
423
+ createEffect(() => {
424
+ void memo.get()
425
+ })
426
+ })
427
+
428
+ expect(cleanedUp).toBe(false)
429
+ dispose()
430
+ expect(cleanedUp).toBe(true)
431
+ })
432
+
433
+ test('should recompute memo when invalidate is called', () => {
434
+ let externalValue = 10
435
+ let computeCount = 0
436
+ let invalidate!: () => void
437
+
438
+ const memo = createMemo(
439
+ () => {
440
+ computeCount++
441
+ return externalValue
442
+ },
443
+ {
444
+ value: 0,
445
+ watched: inv => {
446
+ invalidate = inv
447
+ return () => {}
448
+ },
449
+ },
450
+ )
451
+
452
+ let observed = 0
453
+ const dispose = createScope(() => {
454
+ createEffect(() => {
455
+ observed = memo.get()
456
+ })
457
+ })
458
+
459
+ expect(observed).toBe(10)
460
+ expect(computeCount).toBe(1)
461
+
462
+ externalValue = 20
463
+ invalidate()
464
+ expect(observed).toBe(20)
465
+ expect(computeCount).toBe(2)
466
+
467
+ dispose()
468
+ })
469
+
470
+ test('should defer flush when invalidate is called inside batch', () => {
471
+ let externalValue = 1
472
+ let invalidate!: () => void
473
+
474
+ const memo = createMemo(() => externalValue, {
475
+ value: 0,
476
+ watched: inv => {
477
+ invalidate = inv
478
+ return () => {}
479
+ },
480
+ })
481
+
482
+ let observed = 0
483
+ const dispose = createScope(() => {
484
+ createEffect(() => {
485
+ observed = memo.get()
486
+ })
487
+ })
488
+
489
+ expect(observed).toBe(1)
490
+
491
+ batch(() => {
492
+ externalValue = 2
493
+ invalidate()
494
+ expect(observed).toBe(1) // not yet flushed
495
+ })
496
+ expect(observed).toBe(2) // flushed after batch
497
+
498
+ dispose()
499
+ })
500
+
501
+ test('should re-activate watched after cleanup and new effect access', () => {
502
+ let watchedCount = 0
503
+ const externalValue = 1
504
+
505
+ const memo = createMemo(() => externalValue, {
506
+ value: 0,
507
+ watched: _invalidate => {
508
+ watchedCount++
509
+ return () => {}
510
+ },
511
+ })
512
+
513
+ const dispose1 = createScope(() => {
514
+ createEffect(() => {
515
+ void memo.get()
516
+ })
517
+ })
518
+ expect(watchedCount).toBe(1)
519
+ dispose1()
520
+
521
+ const dispose2 = createScope(() => {
522
+ createEffect(() => {
523
+ void memo.get()
524
+ })
525
+ })
526
+ expect(watchedCount).toBe(2)
527
+ dispose2()
528
+ })
529
+
530
+ test('should work with both tracked dependencies and watched', () => {
531
+ const source = createState(1)
532
+ let externalValue = 100
533
+ let computeCount = 0
534
+ let invalidate!: () => void
535
+
536
+ const memo = createMemo(
537
+ () => {
538
+ computeCount++
539
+ return source.get() + externalValue
540
+ },
541
+ {
542
+ value: 0,
543
+ watched: inv => {
544
+ invalidate = inv
545
+ return () => {}
546
+ },
547
+ },
548
+ )
549
+
550
+ let observed = 0
551
+ const dispose = createScope(() => {
552
+ createEffect(() => {
553
+ observed = memo.get()
554
+ })
555
+ })
556
+
557
+ expect(observed).toBe(101)
558
+ expect(computeCount).toBe(1)
559
+
560
+ // Tracked dependency triggers recomputation
561
+ source.set(2)
562
+ expect(observed).toBe(102)
563
+ expect(computeCount).toBe(2)
564
+
565
+ // External invalidation triggers recomputation
566
+ externalValue = 200
567
+ invalidate()
568
+ expect(observed).toBe(202)
569
+ expect(computeCount).toBe(3)
570
+
571
+ dispose()
572
+ })
573
+ })
380
574
  })
package/test/task.test.ts CHANGED
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'
2
2
  import {
3
3
  createEffect,
4
4
  createMemo,
5
+ createScope,
5
6
  createState,
6
7
  createTask,
7
8
  isMemo,
@@ -392,4 +393,137 @@ describe('Task', () => {
392
393
  }).toThrow('[Task] Signal value cannot be null or undefined')
393
394
  })
394
395
  })
396
+
397
+ describe('options.watched', () => {
398
+ test('should call watched on first effect access', () => {
399
+ let watchedCount = 0
400
+
401
+ const task = createTask(
402
+ async () => {
403
+ await wait(10)
404
+ return 1
405
+ },
406
+ {
407
+ value: 0,
408
+ watched: _invalidate => {
409
+ watchedCount++
410
+ return () => {}
411
+ },
412
+ },
413
+ )
414
+
415
+ expect(watchedCount).toBe(0)
416
+
417
+ const dispose = createScope(() => {
418
+ createEffect(() => {
419
+ void task.get()
420
+ })
421
+ })
422
+
423
+ expect(watchedCount).toBe(1)
424
+ dispose()
425
+ })
426
+
427
+ test('should call cleanup when last effect stops watching', () => {
428
+ let cleanedUp = false
429
+
430
+ const task = createTask(
431
+ async () => {
432
+ await wait(10)
433
+ return 1
434
+ },
435
+ {
436
+ value: 0,
437
+ watched: _invalidate => {
438
+ return () => {
439
+ cleanedUp = true
440
+ }
441
+ },
442
+ },
443
+ )
444
+
445
+ const dispose = createScope(() => {
446
+ createEffect(() => {
447
+ void task.get()
448
+ })
449
+ })
450
+
451
+ expect(cleanedUp).toBe(false)
452
+ dispose()
453
+ expect(cleanedUp).toBe(true)
454
+ })
455
+
456
+ test('should re-execute task when invalidate is called', async () => {
457
+ let externalValue = 10
458
+ let computeCount = 0
459
+ let invalidate!: () => void
460
+
461
+ const task = createTask(
462
+ async () => {
463
+ computeCount++
464
+ await wait(10)
465
+ return externalValue
466
+ },
467
+ {
468
+ value: 0,
469
+ watched: inv => {
470
+ invalidate = inv
471
+ return () => {}
472
+ },
473
+ },
474
+ )
475
+
476
+ let observed = 0
477
+ const dispose = createScope(() => {
478
+ createEffect(() => {
479
+ observed = task.get()
480
+ })
481
+ })
482
+
483
+ await wait(20)
484
+ expect(observed).toBe(10)
485
+ expect(computeCount).toBe(1)
486
+
487
+ externalValue = 20
488
+ invalidate()
489
+ await wait(20)
490
+ expect(observed).toBe(20)
491
+ expect(computeCount).toBe(2)
492
+
493
+ dispose()
494
+ })
495
+
496
+ test('should abort in-flight task when invalidate is called', async () => {
497
+ let wasAborted = false
498
+ let invalidate!: () => void
499
+
500
+ const task = createTask(
501
+ async (_prev, signal) => {
502
+ await wait(100)
503
+ if (signal.aborted) wasAborted = true
504
+ return 1
505
+ },
506
+ {
507
+ value: 0,
508
+ watched: inv => {
509
+ invalidate = inv
510
+ return () => {}
511
+ },
512
+ },
513
+ )
514
+
515
+ const dispose = createScope(() => {
516
+ createEffect(() => {
517
+ void task.get()
518
+ })
519
+ })
520
+
521
+ await wait(10) // task is in-flight
522
+ invalidate() // should trigger re-execution, aborting the current one
523
+ await wait(110)
524
+ expect(wasAborted).toBe(true)
525
+
526
+ dispose()
527
+ })
528
+ })
395
529
  })
package/types/index.d.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * @name Cause & Effect
3
- * @version 0.18.0
3
+ * @version 0.18.1
4
4
  * @author Esther Brunner
5
5
  */
6
6
  export { CircularDependencyError, type Guard, InvalidCallbackError, InvalidSignalValueError, NullishSignalValueError, RequiredOwnerError, UnsetSignalValueError, } from './src/errors';
7
- export { batch, type Cleanup, type ComputedOptions, createScope, type EffectCallback, type MemoCallback, type Signal, type SignalOptions, SKIP_EQUALITY, type TaskCallback, untrack, } from './src/graph';
8
- export { type Collection, type CollectionCallback, type CollectionOptions, createCollection, type DeriveCollectionCallback, isCollection, } from './src/nodes/collection';
7
+ export { batch, type Cleanup, type ComputedOptions, createScope, type EffectCallback, type MaybeCleanup, type MemoCallback, type Signal, type SignalOptions, SKIP_EQUALITY, type TaskCallback, untrack, } from './src/graph';
8
+ export { type Collection, type CollectionCallback, type CollectionChanges, type CollectionOptions, createCollection, type DeriveCollectionCallback, isCollection, } from './src/nodes/collection';
9
9
  export { createEffect, type MatchHandlers, type MaybePromise, match, } from './src/nodes/effect';
10
- export { createList, type DiffResult, isEqual, isList, type KeyConfig, type List, type ListOptions, } from './src/nodes/list';
10
+ export { createList, isEqual, isList, type KeyConfig, type List, type ListOptions, } from './src/nodes/list';
11
11
  export { createMemo, isMemo, type Memo } from './src/nodes/memo';
12
- export { createSensor, isSensor, type Sensor, type SensorCallback, } from './src/nodes/sensor';
12
+ export { createSensor, isSensor, type Sensor, type SensorCallback, type SensorOptions, } from './src/nodes/sensor';
13
13
  export { createState, isState, type State, type UpdateCallback, } from './src/nodes/state';
14
14
  export { createStore, isStore, type Store, type StoreOptions, } from './src/nodes/store';
15
15
  export { createTask, isTask, type Task } from './src/nodes/task';
@@ -76,6 +76,16 @@ type ComputedOptions<T extends {}> = SignalOptions<T> & {
76
76
  * Useful for reducer patterns so that calculations start with a value of correct type.
77
77
  */
78
78
  value?: T;
79
+ /**
80
+ * Optional callback invoked when the signal is first watched by an effect.
81
+ * Receives an `invalidate` function that marks the signal dirty and triggers re-evaluation.
82
+ * Must return a cleanup function that is called when the signal is no longer watched.
83
+ *
84
+ * This enables lazy resource activation for computed signals that need to
85
+ * react to external events (e.g. DOM mutations, timers) in addition to
86
+ * tracked signal dependencies.
87
+ */
88
+ watched?: (invalidate: () => void) => Cleanup;
79
89
  };
80
90
  /**
81
91
  * A callback function for memos that computes a value based on the previous value.
@@ -97,7 +107,7 @@ type TaskCallback<T extends {}> = (prev: T | undefined, signal: AbortSignal) =>
97
107
  /**
98
108
  * A callback function for effects that can perform side effects.
99
109
  *
100
- * @param match - A function to register cleanup callbacks that will be called before the effect re-runs or is disposed
110
+ * @returns An optional cleanup function that will be called before the effect re-runs or is disposed
101
111
  */
102
112
  type EffectCallback = () => MaybeCleanup;
103
113
  declare const TYPE_STATE = "State";
@@ -205,4 +215,4 @@ declare function untrack<T>(fn: () => T): T;
205
215
  * ```
206
216
  */
207
217
  declare function createScope(fn: () => MaybeCleanup): Cleanup;
208
- export { type Cleanup, type ComputedOptions, type EffectCallback, type EffectNode, type MaybeCleanup, type MemoCallback, type MemoNode, type Scope, type Signal, type SignalOptions, type SinkNode, type StateNode, type TaskCallback, type TaskNode, activeOwner, activeSink, batch, batchDepth, createScope, DEFAULT_EQUALITY as defaultEquals, SKIP_EQUALITY, FLAG_CLEAN, FLAG_DIRTY, flush, link, propagate, refresh, registerCleanup, runCleanup, runEffect, setState, trimSources, TYPE_COLLECTION, TYPE_LIST, TYPE_MEMO, TYPE_SENSOR, TYPE_STATE, TYPE_STORE, TYPE_TASK, unlink, untrack, };
218
+ export { type Cleanup, type ComputedOptions, type EffectCallback, type EffectNode, type MaybeCleanup, type MemoCallback, type MemoNode, type Scope, type Signal, type SignalOptions, type SinkNode, type StateNode, type TaskCallback, type TaskNode, activeOwner, activeSink, batch, batchDepth, createScope, DEFAULT_EQUALITY, SKIP_EQUALITY, FLAG_CLEAN, FLAG_DIRTY, flush, link, propagate, refresh, registerCleanup, runCleanup, runEffect, setState, trimSources, TYPE_COLLECTION, TYPE_LIST, TYPE_MEMO, TYPE_SENSOR, TYPE_STATE, TYPE_STORE, TYPE_TASK, unlink, untrack, };
@@ -1,5 +1,5 @@
1
1
  import { type Cleanup, type Signal } from '../graph';
2
- import { type DiffResult, type KeyConfig, type List } from './list';
2
+ import { type KeyConfig, type List } from './list';
3
3
  type CollectionSource<T extends {}> = List<T> | Collection<T>;
4
4
  type DeriveCollectionCallback<T extends {}, U extends {}> = ((sourceValue: U) => T) | ((sourceValue: U, abort: AbortSignal) => Promise<T>);
5
5
  type Collection<T extends {}> = {
@@ -16,12 +16,17 @@ type Collection<T extends {}> = {
16
16
  deriveCollection<R extends {}>(callback: (sourceValue: T, abort: AbortSignal) => Promise<R>): Collection<R>;
17
17
  readonly length: number;
18
18
  };
19
+ type CollectionChanges<T> = {
20
+ add?: T[];
21
+ change?: T[];
22
+ remove?: T[];
23
+ };
19
24
  type CollectionOptions<T extends {}> = {
20
25
  value?: T[];
21
26
  keyConfig?: KeyConfig<T>;
22
- createItem?: (key: string, value: T) => Signal<T>;
27
+ createItem?: (value: T) => Signal<T>;
23
28
  };
24
- type CollectionCallback = (applyChanges: (changes: DiffResult) => void) => Cleanup;
29
+ type CollectionCallback<T extends {}> = (apply: (changes: CollectionChanges<T>) => void) => Cleanup;
25
30
  /**
26
31
  * Creates a derived Collection from a List or another Collection with item-level memoization.
27
32
  * Sync callbacks use createMemo, async callbacks use createTask.
@@ -36,15 +41,15 @@ declare function deriveCollection<T extends {}, U extends {}>(source: Collection
36
41
  declare function deriveCollection<T extends {}, U extends {}>(source: CollectionSource<U>, callback: (sourceValue: U, abort: AbortSignal) => Promise<T>): Collection<T>;
37
42
  /**
38
43
  * Creates an externally-driven Collection with a watched lifecycle.
39
- * Items are managed by the start callback via `applyChanges(diffResult)`.
44
+ * Items are managed via the `applyChanges(changes)` helper passed to the watched callback.
40
45
  * The collection activates when first accessed by an effect and deactivates when no longer watched.
41
46
  *
42
47
  * @since 0.18.0
43
- * @param start - Callback invoked when the collection starts being watched, receives applyChanges helper
48
+ * @param watched - Callback invoked when the collection starts being watched, receives applyChanges helper
44
49
  * @param options - Optional configuration including initial value, key generation, and item signal creation
45
50
  * @returns A read-only Collection signal
46
51
  */
47
- declare function createCollection<T extends {}>(start: CollectionCallback, options?: CollectionOptions<T>): Collection<T>;
52
+ declare function createCollection<T extends {}>(watched: CollectionCallback<T>, options?: CollectionOptions<T>): Collection<T>;
48
53
  /**
49
54
  * Checks if a value is a Collection signal.
50
55
  *
@@ -61,4 +66,4 @@ declare function isCollection<T extends {}>(value: unknown): value is Collection
61
66
  * @returns True if the value is a List or Collection
62
67
  */
63
68
  declare function isCollectionSource<T extends {}>(value: unknown): value is CollectionSource<T>;
64
- export { createCollection, deriveCollection, isCollection, isCollectionSource, type Collection, type CollectionCallback, type CollectionOptions, type CollectionSource, type DeriveCollectionCallback, };
69
+ export { createCollection, deriveCollection, isCollection, isCollectionSource, type Collection, type CollectionCallback, type CollectionChanges, type CollectionOptions, type CollectionSource, type DeriveCollectionCallback, };