atomirx 0.0.2 → 0.0.5

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.
Files changed (65) hide show
  1. package/README.md +868 -161
  2. package/coverage/src/core/onCreateHook.ts.html +72 -70
  3. package/dist/core/atom.d.ts +83 -6
  4. package/dist/core/batch.d.ts +3 -3
  5. package/dist/core/derived.d.ts +69 -22
  6. package/dist/core/effect.d.ts +52 -52
  7. package/dist/core/getAtomState.d.ts +29 -0
  8. package/dist/core/hook.d.ts +1 -1
  9. package/dist/core/onCreateHook.d.ts +37 -23
  10. package/dist/core/onErrorHook.d.ts +49 -0
  11. package/dist/core/promiseCache.d.ts +23 -32
  12. package/dist/core/select.d.ts +208 -29
  13. package/dist/core/types.d.ts +107 -22
  14. package/dist/core/withReady.d.ts +115 -0
  15. package/dist/core/withReady.test.d.ts +1 -0
  16. package/dist/index-CBVj1kSj.js +1350 -0
  17. package/dist/index-Cxk9v0um.cjs +1 -0
  18. package/dist/index.cjs +1 -1
  19. package/dist/index.d.ts +12 -8
  20. package/dist/index.js +18 -15
  21. package/dist/react/index.cjs +10 -10
  22. package/dist/react/index.d.ts +2 -1
  23. package/dist/react/index.js +422 -377
  24. package/dist/react/rx.d.ts +114 -25
  25. package/dist/react/useAction.d.ts +5 -4
  26. package/dist/react/{useValue.d.ts → useSelector.d.ts} +56 -25
  27. package/dist/react/useSelector.test.d.ts +1 -0
  28. package/package.json +1 -1
  29. package/src/core/atom.test.ts +307 -43
  30. package/src/core/atom.ts +144 -22
  31. package/src/core/batch.test.ts +10 -10
  32. package/src/core/batch.ts +3 -3
  33. package/src/core/define.test.ts +12 -11
  34. package/src/core/define.ts +1 -1
  35. package/src/core/derived.test.ts +906 -72
  36. package/src/core/derived.ts +192 -81
  37. package/src/core/effect.test.ts +651 -45
  38. package/src/core/effect.ts +102 -98
  39. package/src/core/getAtomState.ts +69 -0
  40. package/src/core/hook.test.ts +5 -5
  41. package/src/core/hook.ts +1 -1
  42. package/src/core/onCreateHook.ts +38 -23
  43. package/src/core/onErrorHook.test.ts +350 -0
  44. package/src/core/onErrorHook.ts +52 -0
  45. package/src/core/promiseCache.test.ts +5 -3
  46. package/src/core/promiseCache.ts +76 -71
  47. package/src/core/select.ts +405 -130
  48. package/src/core/selector.test.ts +574 -32
  49. package/src/core/types.ts +107 -29
  50. package/src/core/withReady.test.ts +534 -0
  51. package/src/core/withReady.ts +191 -0
  52. package/src/core/withUse.ts +1 -1
  53. package/src/index.test.ts +4 -4
  54. package/src/index.ts +21 -7
  55. package/src/react/index.ts +2 -1
  56. package/src/react/rx.test.tsx +173 -18
  57. package/src/react/rx.tsx +274 -43
  58. package/src/react/useAction.test.ts +12 -14
  59. package/src/react/useAction.ts +11 -9
  60. package/src/react/{useValue.test.ts → useSelector.test.ts} +16 -16
  61. package/src/react/{useValue.ts → useSelector.ts} +64 -33
  62. package/v2.md +44 -44
  63. package/dist/index-2ok7ilik.js +0 -1217
  64. package/dist/index-B_5SFzfl.cjs +0 -1
  65. /package/dist/{react/useValue.test.d.ts → core/onErrorHook.test.d.ts} +0 -0
@@ -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", () => {
@@ -8,8 +9,8 @@ describe("effect", () => {
8
9
  const effectFn = vi.fn();
9
10
  const count$ = atom(0);
10
11
 
11
- effect(({ get }) => {
12
- effectFn(get(count$));
12
+ effect(({ read }) => {
13
+ effectFn(read(count$));
13
14
  });
14
15
 
15
16
  // Wait for async execution
@@ -21,8 +22,8 @@ describe("effect", () => {
21
22
  const effectFn = vi.fn();
22
23
  const count$ = atom(0);
23
24
 
24
- effect(({ get }) => {
25
- effectFn(get(count$));
25
+ effect(({ read }) => {
26
+ effectFn(read(count$));
26
27
  });
27
28
 
28
29
  await new Promise((r) => setTimeout(r, 0));
@@ -39,8 +40,8 @@ describe("effect", () => {
39
40
  const a$ = atom(1);
40
41
  const b$ = atom(2);
41
42
 
42
- effect(({ get }) => {
43
- effectFn(get(a$) + get(b$));
43
+ effect(({ read }) => {
44
+ effectFn(read(a$) + read(b$));
44
45
  });
45
46
 
46
47
  await new Promise((r) => setTimeout(r, 0));
@@ -62,8 +63,8 @@ describe("effect", () => {
62
63
  const effectFn = vi.fn();
63
64
  const count$ = atom(0);
64
65
 
65
- effect(({ get, onCleanup }) => {
66
- effectFn(get(count$));
66
+ effect(({ read, onCleanup }) => {
67
+ effectFn(read(count$));
67
68
  onCleanup(cleanupFn);
68
69
  });
69
70
 
@@ -81,15 +82,15 @@ describe("effect", () => {
81
82
  const cleanupFn = vi.fn();
82
83
  const count$ = atom(0);
83
84
 
84
- const dispose = effect(({ get, onCleanup }) => {
85
- get(count$);
85
+ const e = effect(({ read, onCleanup }) => {
86
+ read(count$);
86
87
  onCleanup(cleanupFn);
87
88
  });
88
89
 
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(({ get }) => {
103
- effectFn(get(count$));
103
+ const e = effect(({ read }) => {
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,31 +119,37 @@ describe("effect", () => {
118
119
  const cleanupFn = vi.fn();
119
120
  const count$ = atom(0);
120
121
 
121
- const dispose = effect(({ get, onCleanup }) => {
122
- get(count$);
122
+ const e = effect(({ read, onCleanup }) => {
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
  });
135
136
 
136
- describe("error handling", () => {
137
- it("should call onError callback when effect throws", async () => {
137
+ describe("error handling with safe()", () => {
138
+ it("should catch errors with safe() and return error tuple", async () => {
138
139
  const errorHandler = vi.fn();
139
140
  const count$ = atom(0);
140
- const error = new Error("Effect error");
141
141
 
142
- effect(({ get, onError }) => {
143
- onError(errorHandler);
144
- if (get(count$) > 0) {
145
- throw error;
142
+ effect(({ read, safe }) => {
143
+ const [err] = safe(() => {
144
+ const count = read(count$);
145
+ if (count > 0) {
146
+ throw new Error("Effect error");
147
+ }
148
+ return count;
149
+ });
150
+
151
+ if (err) {
152
+ errorHandler(err);
146
153
  }
147
154
  });
148
155
 
@@ -151,30 +158,55 @@ describe("effect", () => {
151
158
 
152
159
  count$.set(5);
153
160
  await new Promise((r) => setTimeout(r, 10));
154
- expect(errorHandler).toHaveBeenCalledWith(error);
161
+ expect(errorHandler).toHaveBeenCalledWith(expect.any(Error));
162
+ expect((errorHandler.mock.calls[0][0] as Error).message).toBe(
163
+ "Effect error"
164
+ );
155
165
  });
156
166
 
157
- it("should call options.onError for unhandled errors", async () => {
158
- const onError = vi.fn();
159
- const count$ = atom(0);
160
- const error = new Error("Effect error");
167
+ it("should return success tuple when no error", async () => {
168
+ const results: number[] = [];
169
+ const count$ = atom(5);
161
170
 
162
- effect(
163
- ({ get }) => {
164
- if (get(count$) > 0) {
165
- throw error;
166
- }
167
- },
168
- { onError }
169
- );
171
+ effect(({ read, safe }) => {
172
+ const [err, value] = safe(() => read(count$) * 2);
173
+ if (!err && value !== undefined) {
174
+ results.push(value);
175
+ }
176
+ });
170
177
 
171
178
  await new Promise((r) => setTimeout(r, 0));
172
- expect(onError).not.toHaveBeenCalled();
179
+ expect(results).toEqual([10]);
173
180
 
174
- count$.set(5);
181
+ count$.set(10);
182
+ await new Promise((r) => setTimeout(r, 10));
183
+ expect(results).toEqual([10, 20]);
184
+ });
185
+
186
+ it("should preserve Suspense by re-throwing promises in safe()", async () => {
187
+ const effectFn = vi.fn();
188
+ let resolvePromise: (value: number) => void;
189
+ const promise = new Promise<number>((r) => {
190
+ resolvePromise = r;
191
+ });
192
+ const async$ = atom(promise);
193
+
194
+ effect(({ read, safe }) => {
195
+ // safe() should re-throw the promise, not catch it
196
+ const [err, value] = safe(() => read(async$));
197
+ if (!err) {
198
+ effectFn(value);
199
+ }
200
+ });
201
+
202
+ // Effect should not run yet (waiting for promise)
203
+ await new Promise((r) => setTimeout(r, 0));
204
+ expect(effectFn).not.toHaveBeenCalled();
205
+
206
+ // Resolve the promise
207
+ resolvePromise!(42);
175
208
  await new Promise((r) => setTimeout(r, 10));
176
- // options.onError should be called for unhandled sync errors
177
- expect(onError).toHaveBeenCalledWith(error);
209
+ expect(effectFn).toHaveBeenCalledWith(42);
178
210
  });
179
211
  });
180
212
 
@@ -185,7 +217,7 @@ describe("effect", () => {
185
217
  const b$ = atom(2);
186
218
 
187
219
  effect(({ all }) => {
188
- const [a, b] = all(a$, b$);
220
+ const [a, b] = all([a$, b$]);
189
221
  effectFn(a + b);
190
222
  });
191
223
 
@@ -193,4 +225,578 @@ describe("effect", () => {
193
225
  expect(effectFn).toHaveBeenCalledWith(3);
194
226
  });
195
227
  });
228
+
229
+ describe("ready() - reactive suspension", () => {
230
+ it("should not run effect when ready() value is null", async () => {
231
+ const effectFn = vi.fn();
232
+ const id$ = atom<string | null>(null);
233
+
234
+ effect(({ ready }) => {
235
+ const id = ready(id$);
236
+ effectFn(id);
237
+ });
238
+
239
+ await new Promise((r) => setTimeout(r, 50));
240
+ // Effect should not have run because id is null
241
+ expect(effectFn).not.toHaveBeenCalled();
242
+ });
243
+
244
+ it("should run effect when ready() value becomes non-null", async () => {
245
+ const effectFn = vi.fn();
246
+ const id$ = atom<string | null>(null);
247
+
248
+ effect(({ ready }) => {
249
+ const id = ready(id$);
250
+ effectFn(id);
251
+ });
252
+
253
+ await new Promise((r) => setTimeout(r, 0));
254
+ expect(effectFn).not.toHaveBeenCalled();
255
+
256
+ // Set non-null value
257
+ id$.set("article-123");
258
+ await new Promise((r) => setTimeout(r, 10));
259
+ expect(effectFn).toHaveBeenCalledWith("article-123");
260
+ });
261
+
262
+ it("should re-suspend when ready() value becomes null again", async () => {
263
+ const effectFn = vi.fn();
264
+ const id$ = atom<string | null>("initial");
265
+
266
+ effect(({ ready }) => {
267
+ const id = ready(id$);
268
+ effectFn(id);
269
+ });
270
+
271
+ await new Promise((r) => setTimeout(r, 0));
272
+ expect(effectFn).toHaveBeenCalledWith("initial");
273
+ expect(effectFn).toHaveBeenCalledTimes(1);
274
+
275
+ // Set to null - effect should not run
276
+ id$.set(null);
277
+ await new Promise((r) => setTimeout(r, 10));
278
+ expect(effectFn).toHaveBeenCalledTimes(1); // Still 1
279
+
280
+ // Set back to non-null
281
+ id$.set("new-value");
282
+ await new Promise((r) => setTimeout(r, 10));
283
+ expect(effectFn).toHaveBeenCalledWith("new-value");
284
+ expect(effectFn).toHaveBeenCalledTimes(2);
285
+ });
286
+
287
+ it("should support ready() with selector", async () => {
288
+ const effectFn = vi.fn();
289
+ const user$ = atom<{ id: number; email: string | null }>({
290
+ id: 1,
291
+ email: null,
292
+ });
293
+
294
+ effect(({ ready }) => {
295
+ const email = ready(user$, (u) => u.email);
296
+ effectFn(email);
297
+ });
298
+
299
+ await new Promise((r) => setTimeout(r, 0));
300
+ expect(effectFn).not.toHaveBeenCalled();
301
+
302
+ // Set email
303
+ user$.set({ id: 1, email: "test@example.com" });
304
+ await new Promise((r) => setTimeout(r, 10));
305
+ expect(effectFn).toHaveBeenCalledWith("test@example.com");
306
+ });
307
+
308
+ it("should run cleanup when transitioning from non-null to null", async () => {
309
+ const cleanupFn = vi.fn();
310
+ const effectFn = vi.fn();
311
+ const id$ = atom<string | null>("initial");
312
+
313
+ effect(({ ready, onCleanup }) => {
314
+ const id = ready(id$);
315
+ effectFn(id);
316
+ onCleanup(cleanupFn);
317
+ });
318
+
319
+ await new Promise((r) => setTimeout(r, 0));
320
+ expect(effectFn).toHaveBeenCalledWith("initial");
321
+ expect(cleanupFn).not.toHaveBeenCalled();
322
+
323
+ // Set to null - should trigger cleanup from previous run
324
+ id$.set(null);
325
+ await new Promise((r) => setTimeout(r, 10));
326
+ expect(cleanupFn).toHaveBeenCalledTimes(1);
327
+ });
328
+
329
+ it("should work with multiple ready() calls", async () => {
330
+ const effectFn = vi.fn();
331
+ const firstName$ = atom<string | null>(null);
332
+ const lastName$ = atom<string | null>(null);
333
+
334
+ effect(({ ready }) => {
335
+ const first = ready(firstName$);
336
+ const last = ready(lastName$);
337
+ effectFn(`${first} ${last}`);
338
+ });
339
+
340
+ await new Promise((r) => setTimeout(r, 0));
341
+ expect(effectFn).not.toHaveBeenCalled();
342
+
343
+ // Set only firstName - still suspended
344
+ firstName$.set("John");
345
+ await new Promise((r) => setTimeout(r, 10));
346
+ expect(effectFn).not.toHaveBeenCalled();
347
+
348
+ // Set lastName - effect should run
349
+ lastName$.set("Doe");
350
+ await new Promise((r) => setTimeout(r, 10));
351
+ expect(effectFn).toHaveBeenCalledWith("John Doe");
352
+ });
353
+
354
+ it("should allow mixing ready() with read()", async () => {
355
+ const effectFn = vi.fn();
356
+ const requiredId$ = atom<string | null>(null);
357
+ const optionalLabel$ = atom("default");
358
+
359
+ effect(({ ready, read }) => {
360
+ const id = ready(requiredId$);
361
+ const label = read(optionalLabel$);
362
+ effectFn({ id, label });
363
+ });
364
+
365
+ await new Promise((r) => setTimeout(r, 0));
366
+ expect(effectFn).not.toHaveBeenCalled();
367
+
368
+ // Set required value
369
+ requiredId$.set("123");
370
+ await new Promise((r) => setTimeout(r, 10));
371
+ expect(effectFn).toHaveBeenCalledWith({ id: "123", label: "default" });
372
+
373
+ // Change optional value
374
+ effectFn.mockClear();
375
+ optionalLabel$.set("custom");
376
+ await new Promise((r) => setTimeout(r, 10));
377
+ expect(effectFn).toHaveBeenCalledWith({ id: "123", label: "custom" });
378
+ });
379
+
380
+ it("should handle real-world: sync to localStorage only when user is logged in", async () => {
381
+ const mockStorage: Record<string, string> = {};
382
+ const currentUser$ = atom<{ id: string } | null>(null);
383
+ const preferences$ = atom({ theme: "dark" });
384
+
385
+ effect(({ ready, read, onCleanup }) => {
386
+ const user = ready(currentUser$);
387
+ const prefs = read(preferences$);
388
+
389
+ // Sync preferences to localStorage for logged-in user
390
+ mockStorage[`prefs:${user.id}`] = JSON.stringify(prefs);
391
+
392
+ onCleanup(() => {
393
+ delete mockStorage[`prefs:${user.id}`];
394
+ });
395
+ });
396
+
397
+ await new Promise((r) => setTimeout(r, 0));
398
+ // No user logged in - nothing in storage
399
+ expect(Object.keys(mockStorage)).toHaveLength(0);
400
+
401
+ // User logs in
402
+ currentUser$.set({ id: "u1" });
403
+ await new Promise((r) => setTimeout(r, 10));
404
+ expect(mockStorage["prefs:u1"]).toBe('{"theme":"dark"}');
405
+
406
+ // Preferences change
407
+ preferences$.set({ theme: "light" });
408
+ await new Promise((r) => setTimeout(r, 10));
409
+ expect(mockStorage["prefs:u1"]).toBe('{"theme":"light"}');
410
+
411
+ // User logs out - cleanup runs
412
+ currentUser$.set(null);
413
+ await new Promise((r) => setTimeout(r, 10));
414
+ expect(mockStorage["prefs:u1"]).toBeUndefined();
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
+ });
196
802
  });