atomirx 0.0.4 → 0.0.6

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.
@@ -1,6 +1,7 @@
1
- import { describe, it, expect, vi } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { atom } from "./atom";
3
- import { effect } from "./effect";
3
+ import { effect, Effect } from "./effect";
4
+ import { onCreateHook } from "./onCreateHook";
4
5
 
5
6
  describe("effect", () => {
6
7
  describe("basic functionality", () => {
@@ -81,7 +82,7 @@ describe("effect", () => {
81
82
  const cleanupFn = vi.fn();
82
83
  const count$ = atom(0);
83
84
 
84
- const dispose = effect(({ read, onCleanup }) => {
85
+ const e = effect(({ read, onCleanup }) => {
85
86
  read(count$);
86
87
  onCleanup(cleanupFn);
87
88
  });
@@ -89,7 +90,7 @@ describe("effect", () => {
89
90
  await new Promise((r) => setTimeout(r, 0));
90
91
  expect(cleanupFn).not.toHaveBeenCalled();
91
92
 
92
- dispose();
93
+ e.dispose();
93
94
  expect(cleanupFn).toHaveBeenCalledTimes(1);
94
95
  });
95
96
  });
@@ -99,14 +100,14 @@ describe("effect", () => {
99
100
  const effectFn = vi.fn();
100
101
  const count$ = atom(0);
101
102
 
102
- const dispose = effect(({ read }) => {
103
+ const e = effect(({ read }) => {
103
104
  effectFn(read(count$));
104
105
  });
105
106
 
106
107
  await new Promise((r) => setTimeout(r, 0));
107
108
  expect(effectFn).toHaveBeenCalledTimes(1);
108
109
 
109
- dispose();
110
+ e.dispose();
110
111
 
111
112
  count$.set(5);
112
113
  await new Promise((r) => setTimeout(r, 10));
@@ -118,17 +119,17 @@ describe("effect", () => {
118
119
  const cleanupFn = vi.fn();
119
120
  const count$ = atom(0);
120
121
 
121
- const dispose = effect(({ read, onCleanup }) => {
122
+ const e = effect(({ read, onCleanup }) => {
122
123
  read(count$);
123
124
  onCleanup(cleanupFn);
124
125
  });
125
126
 
126
127
  await new Promise((r) => setTimeout(r, 0));
127
128
 
128
- dispose();
129
+ e.dispose();
129
130
  expect(cleanupFn).toHaveBeenCalledTimes(1);
130
131
 
131
- dispose(); // Second call should be no-op
132
+ e.dispose(); // Second call should be no-op
132
133
  expect(cleanupFn).toHaveBeenCalledTimes(1);
133
134
  });
134
135
  });
@@ -413,4 +414,389 @@ describe("effect", () => {
413
414
  expect(mockStorage["prefs:u1"]).toBeUndefined();
414
415
  });
415
416
  });
417
+
418
+ describe("Effect return type", () => {
419
+ it("should return Effect object with dispose function", () => {
420
+ const e = effect(() => {});
421
+
422
+ expect(e).toHaveProperty("dispose");
423
+ expect(typeof e.dispose).toBe("function");
424
+ });
425
+
426
+ it("should return Effect object with meta when provided", () => {
427
+ const e = effect(() => {}, {
428
+ meta: { key: "myEffect" },
429
+ });
430
+
431
+ expect(e.meta).toEqual({ key: "myEffect" });
432
+ });
433
+
434
+ it("should return Effect object with undefined meta when not provided", () => {
435
+ const e = effect(() => {});
436
+
437
+ expect(e.meta).toBeUndefined();
438
+ });
439
+
440
+ it("should return Effect object that satisfies Effect interface", () => {
441
+ const e: Effect = effect(() => {}, {
442
+ meta: { key: "typedEffect" },
443
+ });
444
+
445
+ // Type check - this should compile
446
+ const dispose: VoidFunction = e.dispose;
447
+ expect(dispose).toBeDefined();
448
+ });
449
+ });
450
+
451
+ describe("onCreateHook", () => {
452
+ beforeEach(() => {
453
+ onCreateHook.reset();
454
+ });
455
+
456
+ afterEach(() => {
457
+ onCreateHook.reset();
458
+ });
459
+
460
+ it("should call onCreateHook when effect is created", () => {
461
+ const hookFn = vi.fn();
462
+ onCreateHook.override(() => hookFn);
463
+
464
+ const e = effect(() => {}, { meta: { key: "testEffect" } });
465
+
466
+ // effect() internally creates a derived atom, so hook is called twice:
467
+ // 1. for the internal derived atom
468
+ // 2. for the effect itself
469
+ const effectCall = hookFn.mock.calls.find(
470
+ (call) => call[0].type === "effect"
471
+ );
472
+ expect(effectCall).toBeDefined();
473
+ expect(effectCall![0]).toEqual({
474
+ type: "effect",
475
+ key: "testEffect",
476
+ meta: { key: "testEffect" },
477
+ instance: e,
478
+ });
479
+ });
480
+
481
+ it("should call onCreateHook with undefined key when not provided", () => {
482
+ const hookFn = vi.fn();
483
+ onCreateHook.override(() => hookFn);
484
+
485
+ const e = effect(() => {});
486
+
487
+ const effectCall = hookFn.mock.calls.find(
488
+ (call) => call[0].type === "effect"
489
+ );
490
+ expect(effectCall).toBeDefined();
491
+ expect(effectCall![0]).toEqual({
492
+ type: "effect",
493
+ key: undefined,
494
+ meta: undefined,
495
+ instance: e,
496
+ });
497
+ });
498
+
499
+ it("should not throw when onCreateHook is undefined", () => {
500
+ onCreateHook.reset();
501
+
502
+ expect(() => effect(() => {})).not.toThrow();
503
+ });
504
+
505
+ it("should call onCreateHook with effect instance that has working dispose", async () => {
506
+ const hookFn = vi.fn();
507
+ onCreateHook.override(() => hookFn);
508
+
509
+ const cleanupFn = vi.fn();
510
+ const count$ = atom(0);
511
+
512
+ effect(({ read, onCleanup }) => {
513
+ read(count$);
514
+ onCleanup(cleanupFn);
515
+ });
516
+
517
+ await new Promise((r) => setTimeout(r, 0));
518
+
519
+ // Get the effect from the hook call (filter out the internal derived atom call)
520
+ const effectCall = hookFn.mock.calls.find(
521
+ (call) => call[0].type === "effect"
522
+ );
523
+ expect(effectCall).toBeDefined();
524
+ const capturedEffect = effectCall![0].instance as Effect;
525
+
526
+ // Dispose should work
527
+ capturedEffect.dispose();
528
+ expect(cleanupFn).toHaveBeenCalledTimes(1);
529
+ });
530
+
531
+ it("should pass correct type discriminator for effects", () => {
532
+ const hookFn = vi.fn();
533
+ onCreateHook.override(() => hookFn);
534
+
535
+ effect(() => {});
536
+
537
+ // Find the effect call (not the internal derived call)
538
+ const effectCall = hookFn.mock.calls.find(
539
+ (call) => call[0].type === "effect"
540
+ );
541
+ expect(effectCall).toBeDefined();
542
+ expect(effectCall![0].type).toBe("effect");
543
+ });
544
+
545
+ it("should allow tracking effects in devtools-like scenario", () => {
546
+ const effects = new Map<string, Effect>();
547
+ onCreateHook.override(() => (info) => {
548
+ if (info.type === "effect" && info.key) {
549
+ effects.set(info.key, info.instance);
550
+ }
551
+ });
552
+
553
+ const e1 = effect(() => {}, { meta: { key: "effect1" } });
554
+ const e2 = effect(() => {}, { meta: { key: "effect2" } });
555
+ effect(() => {}); // Anonymous - should not be tracked
556
+
557
+ expect(effects.size).toBe(2);
558
+ expect(effects.get("effect1")).toBe(e1);
559
+ expect(effects.get("effect2")).toBe(e2);
560
+ });
561
+
562
+ it("should support disposing all tracked effects", async () => {
563
+ const effects: Effect[] = [];
564
+ onCreateHook.override(() => (info) => {
565
+ if (info.type === "effect") {
566
+ effects.push(info.instance);
567
+ }
568
+ });
569
+
570
+ const cleanupFns = [vi.fn(), vi.fn(), vi.fn()];
571
+ const count$ = atom(0);
572
+
573
+ cleanupFns.forEach((cleanup) => {
574
+ effect(({ read, onCleanup }) => {
575
+ read(count$);
576
+ onCleanup(cleanup);
577
+ });
578
+ });
579
+
580
+ await new Promise((r) => setTimeout(r, 0));
581
+
582
+ // Dispose all tracked effects
583
+ effects.forEach((e) => e.dispose());
584
+
585
+ cleanupFns.forEach((cleanup) => {
586
+ expect(cleanup).toHaveBeenCalledTimes(1);
587
+ });
588
+ });
589
+ });
590
+
591
+ describe("onError callback", () => {
592
+ it("should call onError when effect throws synchronously", async () => {
593
+ const onError = vi.fn();
594
+ const source$ = atom(0);
595
+
596
+ effect(
597
+ ({ read }) => {
598
+ const val = read(source$);
599
+ if (val > 0) {
600
+ throw new Error("Effect error");
601
+ }
602
+ },
603
+ { onError }
604
+ );
605
+
606
+ await new Promise((r) => setTimeout(r, 0));
607
+ expect(onError).not.toHaveBeenCalled();
608
+
609
+ // Trigger error
610
+ source$.set(5);
611
+ await new Promise((r) => setTimeout(r, 10));
612
+
613
+ expect(onError).toHaveBeenCalledTimes(1);
614
+ expect((onError.mock.calls[0][0] as Error).message).toBe("Effect error");
615
+ });
616
+
617
+ it("should call onError when async atom dependency rejects", async () => {
618
+ const onError = vi.fn();
619
+
620
+ // Create an atom with a rejecting Promise
621
+ const asyncSource$ = atom(Promise.reject(new Error("Async error")));
622
+
623
+ effect(
624
+ ({ read }) => {
625
+ read(asyncSource$);
626
+ },
627
+ { onError }
628
+ );
629
+
630
+ await new Promise((r) => setTimeout(r, 20));
631
+
632
+ expect(onError).toHaveBeenCalledTimes(1);
633
+ expect((onError.mock.calls[0][0] as Error).message).toBe("Async error");
634
+ });
635
+
636
+ it("should call onError on each recomputation that throws", async () => {
637
+ const onError = vi.fn();
638
+ const source$ = atom(0);
639
+
640
+ effect(
641
+ ({ read }) => {
642
+ const val = read(source$);
643
+ if (val > 0) {
644
+ throw new Error(`Error for ${val}`);
645
+ }
646
+ },
647
+ { onError }
648
+ );
649
+
650
+ await new Promise((r) => setTimeout(r, 0));
651
+ expect(onError).not.toHaveBeenCalled();
652
+
653
+ // First error
654
+ source$.set(1);
655
+ await new Promise((r) => setTimeout(r, 10));
656
+ expect(onError).toHaveBeenCalledTimes(1);
657
+
658
+ // Second error
659
+ source$.set(2);
660
+ await new Promise((r) => setTimeout(r, 10));
661
+ expect(onError).toHaveBeenCalledTimes(2);
662
+ expect((onError.mock.calls[1][0] as Error).message).toBe("Error for 2");
663
+ });
664
+
665
+ it("should not call onError when effect succeeds", async () => {
666
+ const onError = vi.fn();
667
+ const effectFn = vi.fn();
668
+ const source$ = atom(5);
669
+
670
+ effect(
671
+ ({ read }) => {
672
+ effectFn(read(source$));
673
+ },
674
+ { onError }
675
+ );
676
+
677
+ await new Promise((r) => setTimeout(r, 0));
678
+ source$.set(10);
679
+ await new Promise((r) => setTimeout(r, 10));
680
+ source$.set(15);
681
+ await new Promise((r) => setTimeout(r, 10));
682
+
683
+ expect(effectFn).toHaveBeenCalledTimes(3);
684
+ expect(onError).not.toHaveBeenCalled();
685
+ });
686
+
687
+ it("should not call onError for Promise throws (Suspense)", async () => {
688
+ const onError = vi.fn();
689
+ const effectFn = vi.fn();
690
+ let resolvePromise: (value: number) => void;
691
+ const asyncSource$ = atom(
692
+ new Promise<number>((resolve) => {
693
+ resolvePromise = resolve;
694
+ })
695
+ );
696
+
697
+ effect(
698
+ ({ read }) => {
699
+ effectFn(read(asyncSource$));
700
+ },
701
+ { onError }
702
+ );
703
+
704
+ // Still loading - onError should NOT be called
705
+ await new Promise((r) => setTimeout(r, 10));
706
+ expect(onError).not.toHaveBeenCalled();
707
+ expect(effectFn).not.toHaveBeenCalled();
708
+
709
+ // Resolve successfully
710
+ resolvePromise!(5);
711
+ await new Promise((r) => setTimeout(r, 10));
712
+ expect(effectFn).toHaveBeenCalledWith(5);
713
+ expect(onError).not.toHaveBeenCalled();
714
+ });
715
+
716
+ it("should work without onError callback", async () => {
717
+ const source$ = atom(0);
718
+
719
+ // Should not throw even without onError
720
+ effect(({ read }) => {
721
+ const val = read(source$);
722
+ if (val > 0) {
723
+ throw new Error("Error");
724
+ }
725
+ });
726
+
727
+ await new Promise((r) => setTimeout(r, 0));
728
+ source$.set(5);
729
+ await new Promise((r) => setTimeout(r, 10));
730
+ // No crash - test passes
731
+ });
732
+
733
+ it("should allow combining onError with safe() for different error handling strategies", async () => {
734
+ const onError = vi.fn();
735
+ const handledErrors: unknown[] = [];
736
+ const source$ = atom(0);
737
+
738
+ effect(
739
+ ({ read, safe }) => {
740
+ const val = read(source$);
741
+
742
+ // Use safe() for recoverable errors
743
+ const [err] = safe(() => {
744
+ if (val === 1) {
745
+ throw new Error("Handled error");
746
+ }
747
+ return val;
748
+ });
749
+
750
+ if (err) {
751
+ handledErrors.push(err);
752
+ return;
753
+ }
754
+
755
+ // Unhandled errors go to onError
756
+ if (val === 2) {
757
+ throw new Error("Unhandled error");
758
+ }
759
+ },
760
+ { onError }
761
+ );
762
+
763
+ await new Promise((r) => setTimeout(r, 0));
764
+
765
+ // Handled error via safe()
766
+ source$.set(1);
767
+ await new Promise((r) => setTimeout(r, 10));
768
+ expect(handledErrors.length).toBe(1);
769
+ expect(onError).not.toHaveBeenCalled();
770
+
771
+ // Unhandled error goes to onError
772
+ source$.set(2);
773
+ await new Promise((r) => setTimeout(r, 10));
774
+ expect(onError).toHaveBeenCalledTimes(1);
775
+ expect((onError.mock.calls[0][0] as Error).message).toBe(
776
+ "Unhandled error"
777
+ );
778
+ });
779
+
780
+ it("should pass onError to internal derived atom", async () => {
781
+ // This test verifies the implementation detail that effect passes
782
+ // onError to the internal derived atom
783
+ const onError = vi.fn();
784
+ const source$ = atom(0);
785
+
786
+ effect(
787
+ ({ read }) => {
788
+ const val = read(source$);
789
+ if (val > 0) throw new Error("Test");
790
+ },
791
+ { onError }
792
+ );
793
+
794
+ await new Promise((r) => setTimeout(r, 0));
795
+ source$.set(1);
796
+ await new Promise((r) => setTimeout(r, 10));
797
+
798
+ // onError was called, proving it was passed to derived
799
+ expect(onError).toHaveBeenCalledTimes(1);
800
+ });
801
+ });
416
802
  });
@@ -1,8 +1,9 @@
1
1
  import { batch } from "./batch";
2
2
  import { derived } from "./derived";
3
3
  import { emitter } from "./emitter";
4
+ import { EffectInfo, onCreateHook } from "./onCreateHook";
4
5
  import { ReactiveSelector, SelectContext } from "./select";
5
- import { EffectOptions } from "./types";
6
+ import { EffectMeta, EffectOptions } from "./types";
6
7
  import { WithReadySelectContext } from "./withReady";
7
8
 
8
9
  /**
@@ -27,6 +28,11 @@ export interface EffectContext extends SelectContext, WithReadySelectContext {
27
28
  onCleanup: (cleanup: VoidFunction) => void;
28
29
  }
29
30
 
31
+ export interface Effect {
32
+ dispose: VoidFunction;
33
+ meta?: EffectMeta;
34
+ }
35
+
30
36
  /**
31
37
  * Creates a side-effect that runs when accessed atom(s) change.
32
38
  *
@@ -120,42 +126,63 @@ export interface EffectContext extends SelectContext, WithReadySelectContext {
120
126
  */
121
127
  export function effect(
122
128
  fn: ReactiveSelector<void, EffectContext>,
123
- _options?: EffectOptions
124
- ): VoidFunction {
129
+ options?: EffectOptions
130
+ ): Effect {
125
131
  let disposed = false;
126
132
  const cleanupEmitter = emitter();
127
133
 
134
+ // Create the Effect object early so we can build EffectInfo
135
+ const e: Effect = {
136
+ meta: options?.meta,
137
+ dispose: () => {
138
+ // Guard against multiple dispose calls
139
+ if (disposed) return;
140
+
141
+ // Mark as disposed
142
+ disposed = true;
143
+ // Run final cleanup
144
+ cleanupEmitter.emitAndClear();
145
+ },
146
+ };
147
+
148
+ // Create EffectInfo to pass to derived for error attribution
149
+ const effectInfo: EffectInfo = {
150
+ type: "effect",
151
+ key: options?.meta?.key,
152
+ meta: options?.meta,
153
+ instance: e,
154
+ };
155
+
128
156
  // Create a derived atom that runs the effect on each recomputation.
129
- const derivedAtom = derived((context) => {
130
- // Run previous cleanup before next execution
131
- cleanupEmitter.emitAndClear();
157
+ // Pass _errorSource so errors are attributed to the effect, not the internal derived
158
+ const derivedAtom = derived(
159
+ (context) => {
160
+ // Run previous cleanup before next execution
161
+ cleanupEmitter.emitAndClear();
132
162
 
133
- // Skip effect execution if disposed
134
- if (disposed) return;
163
+ // Skip effect execution if disposed
164
+ if (disposed) return;
135
165
 
136
- // Run effect in a batch - multiple atom updates will only notify once
137
- // Cast to EffectContext since we're adding onCleanup to the DerivedContext
138
- const effectContext = {
139
- ...context,
140
- something: true,
141
- onCleanup: cleanupEmitter.on,
142
- } as unknown as EffectContext;
143
- batch(() => fn(effectContext));
144
- });
166
+ // Run effect in a batch - multiple atom updates will only notify once
167
+ // Cast to EffectContext since we're adding onCleanup to the DerivedContext
168
+ const effectContext = {
169
+ ...context,
170
+ onCleanup: cleanupEmitter.on,
171
+ } as unknown as EffectContext;
172
+ batch(() => fn(effectContext));
173
+ },
174
+ {
175
+ onError: options?.onError,
176
+ _errorSource: effectInfo,
177
+ }
178
+ );
145
179
 
146
180
  // Access .get() to trigger initial computation (derived is lazy)
147
- // Ignore promise rejection - errors should be handled via safe()
148
- derivedAtom.get().catch(() => {
149
- // Silently ignore - use safe() for error handling
150
- });
181
+ // Errors are handled via onError callback or safe() in the effect function
182
+ derivedAtom.get();
151
183
 
152
- return () => {
153
- // Guard against multiple dispose calls
154
- if (disposed) return;
184
+ // Notify devtools/plugins of effect creation
185
+ onCreateHook.current?.(effectInfo);
155
186
 
156
- // Mark as disposed
157
- disposed = true;
158
- // Run final cleanup
159
- cleanupEmitter.emitAndClear();
160
- };
187
+ return e;
161
188
  }
@@ -176,20 +176,20 @@ describe("hook", () => {
176
176
  const hookA = hook<string | undefined>();
177
177
  const hookB = hook<string | undefined>();
178
178
 
179
- // Create custom setups that track release order
179
+ // Create custom setups that track release order using override/reset
180
180
  const setupA = () => {
181
- hookA.current = "A";
181
+ hookA.override(() => "A");
182
182
  return () => {
183
183
  order.push("release A");
184
- hookA.current = undefined;
184
+ hookA.reset();
185
185
  };
186
186
  };
187
187
 
188
188
  const setupB = () => {
189
- hookB.current = "B";
189
+ hookB.override(() => "B");
190
190
  return () => {
191
191
  order.push("release B");
192
- hookB.current = undefined;
192
+ hookB.reset();
193
193
  };
194
194
  };
195
195
 
package/src/core/hook.ts CHANGED
@@ -56,7 +56,7 @@ export interface Hook<T> {
56
56
  /**
57
57
  * Current value of the hook. Direct property access for fast reads.
58
58
  */
59
- current: T;
59
+ readonly current: T;
60
60
 
61
61
  /**
62
62
  * Override the current value using a reducer.