atomirx 0.0.8 → 0.1.0

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 (138) hide show
  1. package/README.md +198 -2234
  2. package/bin/cli.js +90 -0
  3. package/dist/core/derived.d.ts +2 -2
  4. package/dist/core/effect.d.ts +3 -2
  5. package/dist/core/onCreateHook.d.ts +15 -2
  6. package/dist/core/onErrorHook.d.ts +4 -1
  7. package/dist/core/pool.d.ts +78 -0
  8. package/dist/core/pool.test.d.ts +1 -0
  9. package/dist/core/select-boolean.test.d.ts +1 -0
  10. package/dist/core/select-pool.test.d.ts +1 -0
  11. package/dist/core/select.d.ts +278 -86
  12. package/dist/core/types.d.ts +233 -1
  13. package/dist/core/withAbort.d.ts +95 -0
  14. package/dist/core/withReady.d.ts +3 -3
  15. package/dist/devtools/constants.d.ts +41 -0
  16. package/dist/devtools/index.cjs +1 -0
  17. package/dist/devtools/index.d.ts +29 -0
  18. package/dist/devtools/index.js +429 -0
  19. package/dist/devtools/registry.d.ts +98 -0
  20. package/dist/devtools/registry.test.d.ts +1 -0
  21. package/dist/devtools/setup.d.ts +61 -0
  22. package/dist/devtools/types.d.ts +311 -0
  23. package/dist/index-BZEnfIcB.cjs +1 -0
  24. package/dist/index-BbPZhsDl.js +1653 -0
  25. package/dist/index.cjs +1 -1
  26. package/dist/index.d.ts +4 -3
  27. package/dist/index.js +18 -14
  28. package/dist/onDispatchHook-C8yLzr-o.cjs +1 -0
  29. package/dist/onDispatchHook-SKbiIUaJ.js +5 -0
  30. package/dist/onErrorHook-BGGy3tqK.js +38 -0
  31. package/dist/onErrorHook-DHBASmYw.cjs +1 -0
  32. package/dist/react/index.cjs +1 -1
  33. package/dist/react/index.js +191 -151
  34. package/dist/react/onDispatchHook.d.ts +106 -0
  35. package/dist/react/useAction.d.ts +4 -1
  36. package/dist/react-devtools/DevToolsPanel.d.ts +93 -0
  37. package/dist/react-devtools/EntityDetails.d.ts +10 -0
  38. package/dist/react-devtools/EntityList.d.ts +15 -0
  39. package/dist/react-devtools/LogList.d.ts +12 -0
  40. package/dist/react-devtools/hooks.d.ts +50 -0
  41. package/dist/react-devtools/index.cjs +1 -0
  42. package/dist/react-devtools/index.d.ts +31 -0
  43. package/dist/react-devtools/index.js +1589 -0
  44. package/dist/react-devtools/styles.d.ts +148 -0
  45. package/package.json +26 -2
  46. package/skills/atomirx/SKILL.md +456 -0
  47. package/skills/atomirx/references/async-patterns.md +188 -0
  48. package/skills/atomirx/references/atom-patterns.md +238 -0
  49. package/skills/atomirx/references/deferred-loading.md +191 -0
  50. package/skills/atomirx/references/derived-patterns.md +428 -0
  51. package/skills/atomirx/references/effect-patterns.md +426 -0
  52. package/skills/atomirx/references/error-handling.md +140 -0
  53. package/skills/atomirx/references/hooks.md +322 -0
  54. package/skills/atomirx/references/pool-patterns.md +229 -0
  55. package/skills/atomirx/references/react-integration.md +411 -0
  56. package/skills/atomirx/references/rules.md +407 -0
  57. package/skills/atomirx/references/select-context.md +309 -0
  58. package/skills/atomirx/references/service-template.md +172 -0
  59. package/skills/atomirx/references/store-template.md +205 -0
  60. package/skills/atomirx/references/testing-patterns.md +431 -0
  61. package/coverage/base.css +0 -224
  62. package/coverage/block-navigation.js +0 -87
  63. package/coverage/clover.xml +0 -1440
  64. package/coverage/coverage-final.json +0 -14
  65. package/coverage/favicon.png +0 -0
  66. package/coverage/index.html +0 -131
  67. package/coverage/prettify.css +0 -1
  68. package/coverage/prettify.js +0 -2
  69. package/coverage/sort-arrow-sprite.png +0 -0
  70. package/coverage/sorter.js +0 -210
  71. package/coverage/src/core/atom.ts.html +0 -889
  72. package/coverage/src/core/batch.ts.html +0 -223
  73. package/coverage/src/core/define.ts.html +0 -805
  74. package/coverage/src/core/emitter.ts.html +0 -919
  75. package/coverage/src/core/equality.ts.html +0 -631
  76. package/coverage/src/core/hook.ts.html +0 -460
  77. package/coverage/src/core/index.html +0 -281
  78. package/coverage/src/core/isAtom.ts.html +0 -100
  79. package/coverage/src/core/isPromiseLike.ts.html +0 -133
  80. package/coverage/src/core/onCreateHook.ts.html +0 -138
  81. package/coverage/src/core/scheduleNotifyHook.ts.html +0 -94
  82. package/coverage/src/core/types.ts.html +0 -523
  83. package/coverage/src/core/withUse.ts.html +0 -253
  84. package/coverage/src/index.html +0 -116
  85. package/coverage/src/index.ts.html +0 -106
  86. package/dist/index-CBVj1kSj.js +0 -1350
  87. package/dist/index-Cxk9v0um.cjs +0 -1
  88. package/scripts/publish.js +0 -198
  89. package/src/core/atom.test.ts +0 -633
  90. package/src/core/atom.ts +0 -311
  91. package/src/core/atomState.test.ts +0 -342
  92. package/src/core/atomState.ts +0 -256
  93. package/src/core/batch.test.ts +0 -257
  94. package/src/core/batch.ts +0 -172
  95. package/src/core/define.test.ts +0 -343
  96. package/src/core/define.ts +0 -243
  97. package/src/core/derived.test.ts +0 -1215
  98. package/src/core/derived.ts +0 -450
  99. package/src/core/effect.test.ts +0 -802
  100. package/src/core/effect.ts +0 -188
  101. package/src/core/emitter.test.ts +0 -364
  102. package/src/core/emitter.ts +0 -392
  103. package/src/core/equality.test.ts +0 -392
  104. package/src/core/equality.ts +0 -182
  105. package/src/core/getAtomState.ts +0 -69
  106. package/src/core/hook.test.ts +0 -227
  107. package/src/core/hook.ts +0 -177
  108. package/src/core/isAtom.ts +0 -27
  109. package/src/core/isPromiseLike.test.ts +0 -72
  110. package/src/core/isPromiseLike.ts +0 -16
  111. package/src/core/onCreateHook.ts +0 -107
  112. package/src/core/onErrorHook.test.ts +0 -350
  113. package/src/core/onErrorHook.ts +0 -52
  114. package/src/core/promiseCache.test.ts +0 -241
  115. package/src/core/promiseCache.ts +0 -284
  116. package/src/core/scheduleNotifyHook.ts +0 -53
  117. package/src/core/select.ts +0 -729
  118. package/src/core/selector.test.ts +0 -799
  119. package/src/core/types.ts +0 -389
  120. package/src/core/withReady.test.ts +0 -534
  121. package/src/core/withReady.ts +0 -191
  122. package/src/core/withUse.test.ts +0 -249
  123. package/src/core/withUse.ts +0 -56
  124. package/src/index.test.ts +0 -80
  125. package/src/index.ts +0 -65
  126. package/src/react/index.ts +0 -21
  127. package/src/react/rx.test.tsx +0 -571
  128. package/src/react/rx.tsx +0 -531
  129. package/src/react/strictModeTest.tsx +0 -71
  130. package/src/react/useAction.test.ts +0 -987
  131. package/src/react/useAction.ts +0 -607
  132. package/src/react/useSelector.test.ts +0 -182
  133. package/src/react/useSelector.ts +0 -292
  134. package/src/react/useStable.test.ts +0 -553
  135. package/src/react/useStable.ts +0 -288
  136. package/tsconfig.json +0 -9
  137. package/v2.md +0 -725
  138. package/vite.config.ts +0 -42
@@ -1,802 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { atom } from "./atom";
3
- import { effect, Effect } from "./effect";
4
- import { onCreateHook } from "./onCreateHook";
5
-
6
- describe("effect", () => {
7
- describe("basic functionality", () => {
8
- it("should run effect immediately", async () => {
9
- const effectFn = vi.fn();
10
- const count$ = atom(0);
11
-
12
- effect(({ read }) => {
13
- effectFn(read(count$));
14
- });
15
-
16
- // Wait for async execution
17
- await new Promise((r) => setTimeout(r, 0));
18
- expect(effectFn).toHaveBeenCalledWith(0);
19
- });
20
-
21
- it("should run effect when dependency changes", async () => {
22
- const effectFn = vi.fn();
23
- const count$ = atom(0);
24
-
25
- effect(({ read }) => {
26
- effectFn(read(count$));
27
- });
28
-
29
- await new Promise((r) => setTimeout(r, 0));
30
- expect(effectFn).toHaveBeenCalledTimes(1);
31
-
32
- count$.set(5);
33
- await new Promise((r) => setTimeout(r, 10));
34
- expect(effectFn).toHaveBeenCalledTimes(2);
35
- expect(effectFn).toHaveBeenLastCalledWith(5);
36
- });
37
-
38
- it("should track multiple dependencies", async () => {
39
- const effectFn = vi.fn();
40
- const a$ = atom(1);
41
- const b$ = atom(2);
42
-
43
- effect(({ read }) => {
44
- effectFn(read(a$) + read(b$));
45
- });
46
-
47
- await new Promise((r) => setTimeout(r, 0));
48
- expect(effectFn).toHaveBeenCalledWith(3);
49
-
50
- a$.set(10);
51
- await new Promise((r) => setTimeout(r, 10));
52
- expect(effectFn).toHaveBeenCalledWith(12);
53
-
54
- b$.set(5);
55
- await new Promise((r) => setTimeout(r, 10));
56
- expect(effectFn).toHaveBeenCalledWith(15);
57
- });
58
- });
59
-
60
- describe("cleanup", () => {
61
- it("should run cleanup before next execution", async () => {
62
- const cleanupFn = vi.fn();
63
- const effectFn = vi.fn();
64
- const count$ = atom(0);
65
-
66
- effect(({ read, onCleanup }) => {
67
- effectFn(read(count$));
68
- onCleanup(cleanupFn);
69
- });
70
-
71
- await new Promise((r) => setTimeout(r, 0));
72
- expect(effectFn).toHaveBeenCalledTimes(1);
73
- expect(cleanupFn).not.toHaveBeenCalled();
74
-
75
- count$.set(1);
76
- await new Promise((r) => setTimeout(r, 10));
77
- expect(effectFn).toHaveBeenCalledTimes(2);
78
- expect(cleanupFn).toHaveBeenCalledTimes(1);
79
- });
80
-
81
- it("should run cleanup on dispose", async () => {
82
- const cleanupFn = vi.fn();
83
- const count$ = atom(0);
84
-
85
- const e = effect(({ read, onCleanup }) => {
86
- read(count$);
87
- onCleanup(cleanupFn);
88
- });
89
-
90
- await new Promise((r) => setTimeout(r, 0));
91
- expect(cleanupFn).not.toHaveBeenCalled();
92
-
93
- e.dispose();
94
- expect(cleanupFn).toHaveBeenCalledTimes(1);
95
- });
96
- });
97
-
98
- describe("dispose", () => {
99
- it("should stop effect execution after dispose", async () => {
100
- const effectFn = vi.fn();
101
- const count$ = atom(0);
102
-
103
- const e = effect(({ read }) => {
104
- effectFn(read(count$));
105
- });
106
-
107
- await new Promise((r) => setTimeout(r, 0));
108
- expect(effectFn).toHaveBeenCalledTimes(1);
109
-
110
- e.dispose();
111
-
112
- count$.set(5);
113
- await new Promise((r) => setTimeout(r, 10));
114
- // Should not have been called again
115
- expect(effectFn).toHaveBeenCalledTimes(1);
116
- });
117
-
118
- it("should be safe to call dispose multiple times", async () => {
119
- const cleanupFn = vi.fn();
120
- const count$ = atom(0);
121
-
122
- const e = effect(({ read, onCleanup }) => {
123
- read(count$);
124
- onCleanup(cleanupFn);
125
- });
126
-
127
- await new Promise((r) => setTimeout(r, 0));
128
-
129
- e.dispose();
130
- expect(cleanupFn).toHaveBeenCalledTimes(1);
131
-
132
- e.dispose(); // Second call should be no-op
133
- expect(cleanupFn).toHaveBeenCalledTimes(1);
134
- });
135
- });
136
-
137
- describe("error handling with safe()", () => {
138
- it("should catch errors with safe() and return error tuple", async () => {
139
- const errorHandler = vi.fn();
140
- const count$ = atom(0);
141
-
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);
153
- }
154
- });
155
-
156
- await new Promise((r) => setTimeout(r, 0));
157
- expect(errorHandler).not.toHaveBeenCalled();
158
-
159
- count$.set(5);
160
- await new Promise((r) => setTimeout(r, 10));
161
- expect(errorHandler).toHaveBeenCalledWith(expect.any(Error));
162
- expect((errorHandler.mock.calls[0][0] as Error).message).toBe(
163
- "Effect error"
164
- );
165
- });
166
-
167
- it("should return success tuple when no error", async () => {
168
- const results: number[] = [];
169
- const count$ = atom(5);
170
-
171
- effect(({ read, safe }) => {
172
- const [err, value] = safe(() => read(count$) * 2);
173
- if (!err && value !== undefined) {
174
- results.push(value);
175
- }
176
- });
177
-
178
- await new Promise((r) => setTimeout(r, 0));
179
- expect(results).toEqual([10]);
180
-
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);
208
- await new Promise((r) => setTimeout(r, 10));
209
- expect(effectFn).toHaveBeenCalledWith(42);
210
- });
211
- });
212
-
213
- describe("context utilities", () => {
214
- it("should support all() for multiple atoms", async () => {
215
- const effectFn = vi.fn();
216
- const a$ = atom(1);
217
- const b$ = atom(2);
218
-
219
- effect(({ all }) => {
220
- const [a, b] = all([a$, b$]);
221
- effectFn(a + b);
222
- });
223
-
224
- await new Promise((r) => setTimeout(r, 0));
225
- expect(effectFn).toHaveBeenCalledWith(3);
226
- });
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
- });
802
- });