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.
- package/README.md +2 -2
- package/coverage/src/core/onCreateHook.ts.html +72 -70
- package/dist/core/derived.d.ts +15 -2
- package/dist/core/effect.d.ts +6 -2
- package/dist/core/hook.d.ts +1 -1
- package/dist/core/onCreateHook.d.ts +37 -23
- package/dist/core/onErrorHook.d.ts +49 -0
- package/dist/core/onErrorHook.test.d.ts +1 -0
- package/dist/core/types.d.ts +52 -3
- package/dist/core/withReady.d.ts +46 -0
- package/dist/index-CBVj1kSj.js +1350 -0
- package/dist/index-Cxk9v0um.cjs +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +12 -11
- package/dist/react/index.cjs +9 -9
- package/dist/react/index.js +76 -75
- package/package.json +5 -5
- package/src/core/atom.ts +1 -1
- package/src/core/define.test.ts +12 -11
- package/src/core/define.ts +1 -1
- package/src/core/derived.test.ts +179 -0
- package/src/core/derived.ts +51 -8
- package/src/core/effect.test.ts +395 -9
- package/src/core/effect.ts +56 -29
- package/src/core/hook.test.ts +5 -5
- package/src/core/hook.ts +1 -1
- package/src/core/onCreateHook.ts +38 -23
- package/src/core/onErrorHook.test.ts +350 -0
- package/src/core/onErrorHook.ts +52 -0
- package/src/core/types.ts +53 -3
- package/src/core/withReady.test.ts +174 -0
- package/src/core/withReady.ts +91 -27
- package/src/index.ts +10 -1
- package/dist/index-CqO6BDwj.cjs +0 -1
- package/dist/index-D8RDOTB_.js +0 -1319
package/src/core/effect.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
});
|
package/src/core/effect.ts
CHANGED
|
@@ -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
|
-
|
|
124
|
-
):
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
163
|
+
// Skip effect execution if disposed
|
|
164
|
+
if (disposed) return;
|
|
135
165
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
//
|
|
148
|
-
derivedAtom.get()
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
if (disposed) return;
|
|
184
|
+
// Notify devtools/plugins of effect creation
|
|
185
|
+
onCreateHook.current?.(effectInfo);
|
|
155
186
|
|
|
156
|
-
|
|
157
|
-
disposed = true;
|
|
158
|
-
// Run final cleanup
|
|
159
|
-
cleanupEmitter.emitAndClear();
|
|
160
|
-
};
|
|
187
|
+
return e;
|
|
161
188
|
}
|
package/src/core/hook.test.ts
CHANGED
|
@@ -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.
|
|
181
|
+
hookA.override(() => "A");
|
|
182
182
|
return () => {
|
|
183
183
|
order.push("release A");
|
|
184
|
-
hookA.
|
|
184
|
+
hookA.reset();
|
|
185
185
|
};
|
|
186
186
|
};
|
|
187
187
|
|
|
188
188
|
const setupB = () => {
|
|
189
|
-
hookB.
|
|
189
|
+
hookB.override(() => "B");
|
|
190
190
|
return () => {
|
|
191
191
|
order.push("release B");
|
|
192
|
-
hookB.
|
|
192
|
+
hookB.reset();
|
|
193
193
|
};
|
|
194
194
|
};
|
|
195
195
|
|