atomirx 0.0.2 → 0.0.4

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 (53) hide show
  1. package/README.md +866 -159
  2. package/dist/core/atom.d.ts +83 -6
  3. package/dist/core/batch.d.ts +3 -3
  4. package/dist/core/derived.d.ts +55 -21
  5. package/dist/core/effect.d.ts +47 -51
  6. package/dist/core/getAtomState.d.ts +29 -0
  7. package/dist/core/promiseCache.d.ts +23 -32
  8. package/dist/core/select.d.ts +208 -29
  9. package/dist/core/types.d.ts +55 -19
  10. package/dist/core/withReady.d.ts +69 -0
  11. package/dist/index-CqO6BDwj.cjs +1 -0
  12. package/dist/index-D8RDOTB_.js +1319 -0
  13. package/dist/index.cjs +1 -1
  14. package/dist/index.d.ts +9 -7
  15. package/dist/index.js +12 -10
  16. package/dist/react/index.cjs +10 -10
  17. package/dist/react/index.d.ts +2 -1
  18. package/dist/react/index.js +423 -379
  19. package/dist/react/rx.d.ts +114 -25
  20. package/dist/react/useAction.d.ts +5 -4
  21. package/dist/react/{useValue.d.ts → useSelector.d.ts} +56 -25
  22. package/dist/react/useSelector.test.d.ts +1 -0
  23. package/package.json +1 -1
  24. package/src/core/atom.test.ts +307 -43
  25. package/src/core/atom.ts +143 -21
  26. package/src/core/batch.test.ts +10 -10
  27. package/src/core/batch.ts +3 -3
  28. package/src/core/derived.test.ts +727 -72
  29. package/src/core/derived.ts +141 -73
  30. package/src/core/effect.test.ts +259 -39
  31. package/src/core/effect.ts +62 -85
  32. package/src/core/getAtomState.ts +69 -0
  33. package/src/core/promiseCache.test.ts +5 -3
  34. package/src/core/promiseCache.ts +76 -71
  35. package/src/core/select.ts +405 -130
  36. package/src/core/selector.test.ts +574 -32
  37. package/src/core/types.ts +54 -26
  38. package/src/core/withReady.test.ts +360 -0
  39. package/src/core/withReady.ts +127 -0
  40. package/src/core/withUse.ts +1 -1
  41. package/src/index.test.ts +4 -4
  42. package/src/index.ts +11 -6
  43. package/src/react/index.ts +2 -1
  44. package/src/react/rx.test.tsx +173 -18
  45. package/src/react/rx.tsx +274 -43
  46. package/src/react/useAction.test.ts +12 -14
  47. package/src/react/useAction.ts +11 -9
  48. package/src/react/{useValue.test.ts → useSelector.test.ts} +16 -16
  49. package/src/react/{useValue.ts → useSelector.ts} +64 -33
  50. package/v2.md +44 -44
  51. package/dist/index-2ok7ilik.js +0 -1217
  52. package/dist/index-B_5SFzfl.cjs +0 -1
  53. /package/dist/{react/useValue.test.d.ts → core/withReady.test.d.ts} +0 -0
@@ -8,8 +8,8 @@ describe("effect", () => {
8
8
  const effectFn = vi.fn();
9
9
  const count$ = atom(0);
10
10
 
11
- effect(({ get }) => {
12
- effectFn(get(count$));
11
+ effect(({ read }) => {
12
+ effectFn(read(count$));
13
13
  });
14
14
 
15
15
  // Wait for async execution
@@ -21,8 +21,8 @@ describe("effect", () => {
21
21
  const effectFn = vi.fn();
22
22
  const count$ = atom(0);
23
23
 
24
- effect(({ get }) => {
25
- effectFn(get(count$));
24
+ effect(({ read }) => {
25
+ effectFn(read(count$));
26
26
  });
27
27
 
28
28
  await new Promise((r) => setTimeout(r, 0));
@@ -39,8 +39,8 @@ describe("effect", () => {
39
39
  const a$ = atom(1);
40
40
  const b$ = atom(2);
41
41
 
42
- effect(({ get }) => {
43
- effectFn(get(a$) + get(b$));
42
+ effect(({ read }) => {
43
+ effectFn(read(a$) + read(b$));
44
44
  });
45
45
 
46
46
  await new Promise((r) => setTimeout(r, 0));
@@ -62,8 +62,8 @@ describe("effect", () => {
62
62
  const effectFn = vi.fn();
63
63
  const count$ = atom(0);
64
64
 
65
- effect(({ get, onCleanup }) => {
66
- effectFn(get(count$));
65
+ effect(({ read, onCleanup }) => {
66
+ effectFn(read(count$));
67
67
  onCleanup(cleanupFn);
68
68
  });
69
69
 
@@ -81,8 +81,8 @@ describe("effect", () => {
81
81
  const cleanupFn = vi.fn();
82
82
  const count$ = atom(0);
83
83
 
84
- const dispose = effect(({ get, onCleanup }) => {
85
- get(count$);
84
+ const dispose = effect(({ read, onCleanup }) => {
85
+ read(count$);
86
86
  onCleanup(cleanupFn);
87
87
  });
88
88
 
@@ -99,8 +99,8 @@ describe("effect", () => {
99
99
  const effectFn = vi.fn();
100
100
  const count$ = atom(0);
101
101
 
102
- const dispose = effect(({ get }) => {
103
- effectFn(get(count$));
102
+ const dispose = effect(({ read }) => {
103
+ effectFn(read(count$));
104
104
  });
105
105
 
106
106
  await new Promise((r) => setTimeout(r, 0));
@@ -118,8 +118,8 @@ describe("effect", () => {
118
118
  const cleanupFn = vi.fn();
119
119
  const count$ = atom(0);
120
120
 
121
- const dispose = effect(({ get, onCleanup }) => {
122
- get(count$);
121
+ const dispose = effect(({ read, onCleanup }) => {
122
+ read(count$);
123
123
  onCleanup(cleanupFn);
124
124
  });
125
125
 
@@ -133,16 +133,22 @@ describe("effect", () => {
133
133
  });
134
134
  });
135
135
 
136
- describe("error handling", () => {
137
- it("should call onError callback when effect throws", async () => {
136
+ describe("error handling with safe()", () => {
137
+ it("should catch errors with safe() and return error tuple", async () => {
138
138
  const errorHandler = vi.fn();
139
139
  const count$ = atom(0);
140
- const error = new Error("Effect error");
141
140
 
142
- effect(({ get, onError }) => {
143
- onError(errorHandler);
144
- if (get(count$) > 0) {
145
- throw error;
141
+ effect(({ read, safe }) => {
142
+ const [err] = safe(() => {
143
+ const count = read(count$);
144
+ if (count > 0) {
145
+ throw new Error("Effect error");
146
+ }
147
+ return count;
148
+ });
149
+
150
+ if (err) {
151
+ errorHandler(err);
146
152
  }
147
153
  });
148
154
 
@@ -151,30 +157,55 @@ describe("effect", () => {
151
157
 
152
158
  count$.set(5);
153
159
  await new Promise((r) => setTimeout(r, 10));
154
- expect(errorHandler).toHaveBeenCalledWith(error);
160
+ expect(errorHandler).toHaveBeenCalledWith(expect.any(Error));
161
+ expect((errorHandler.mock.calls[0][0] as Error).message).toBe(
162
+ "Effect error"
163
+ );
155
164
  });
156
165
 
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");
166
+ it("should return success tuple when no error", async () => {
167
+ const results: number[] = [];
168
+ const count$ = atom(5);
161
169
 
162
- effect(
163
- ({ get }) => {
164
- if (get(count$) > 0) {
165
- throw error;
166
- }
167
- },
168
- { onError }
169
- );
170
+ effect(({ read, safe }) => {
171
+ const [err, value] = safe(() => read(count$) * 2);
172
+ if (!err && value !== undefined) {
173
+ results.push(value);
174
+ }
175
+ });
170
176
 
171
177
  await new Promise((r) => setTimeout(r, 0));
172
- expect(onError).not.toHaveBeenCalled();
178
+ expect(results).toEqual([10]);
173
179
 
174
- count$.set(5);
180
+ count$.set(10);
175
181
  await new Promise((r) => setTimeout(r, 10));
176
- // options.onError should be called for unhandled sync errors
177
- expect(onError).toHaveBeenCalledWith(error);
182
+ expect(results).toEqual([10, 20]);
183
+ });
184
+
185
+ it("should preserve Suspense by re-throwing promises in safe()", async () => {
186
+ const effectFn = vi.fn();
187
+ let resolvePromise: (value: number) => void;
188
+ const promise = new Promise<number>((r) => {
189
+ resolvePromise = r;
190
+ });
191
+ const async$ = atom(promise);
192
+
193
+ effect(({ read, safe }) => {
194
+ // safe() should re-throw the promise, not catch it
195
+ const [err, value] = safe(() => read(async$));
196
+ if (!err) {
197
+ effectFn(value);
198
+ }
199
+ });
200
+
201
+ // Effect should not run yet (waiting for promise)
202
+ await new Promise((r) => setTimeout(r, 0));
203
+ expect(effectFn).not.toHaveBeenCalled();
204
+
205
+ // Resolve the promise
206
+ resolvePromise!(42);
207
+ await new Promise((r) => setTimeout(r, 10));
208
+ expect(effectFn).toHaveBeenCalledWith(42);
178
209
  });
179
210
  });
180
211
 
@@ -185,7 +216,7 @@ describe("effect", () => {
185
216
  const b$ = atom(2);
186
217
 
187
218
  effect(({ all }) => {
188
- const [a, b] = all(a$, b$);
219
+ const [a, b] = all([a$, b$]);
189
220
  effectFn(a + b);
190
221
  });
191
222
 
@@ -193,4 +224,193 @@ describe("effect", () => {
193
224
  expect(effectFn).toHaveBeenCalledWith(3);
194
225
  });
195
226
  });
227
+
228
+ describe("ready() - reactive suspension", () => {
229
+ it("should not run effect when ready() value is null", async () => {
230
+ const effectFn = vi.fn();
231
+ const id$ = atom<string | null>(null);
232
+
233
+ effect(({ ready }) => {
234
+ const id = ready(id$);
235
+ effectFn(id);
236
+ });
237
+
238
+ await new Promise((r) => setTimeout(r, 50));
239
+ // Effect should not have run because id is null
240
+ expect(effectFn).not.toHaveBeenCalled();
241
+ });
242
+
243
+ it("should run effect when ready() value becomes non-null", async () => {
244
+ const effectFn = vi.fn();
245
+ const id$ = atom<string | null>(null);
246
+
247
+ effect(({ ready }) => {
248
+ const id = ready(id$);
249
+ effectFn(id);
250
+ });
251
+
252
+ await new Promise((r) => setTimeout(r, 0));
253
+ expect(effectFn).not.toHaveBeenCalled();
254
+
255
+ // Set non-null value
256
+ id$.set("article-123");
257
+ await new Promise((r) => setTimeout(r, 10));
258
+ expect(effectFn).toHaveBeenCalledWith("article-123");
259
+ });
260
+
261
+ it("should re-suspend when ready() value becomes null again", async () => {
262
+ const effectFn = vi.fn();
263
+ const id$ = atom<string | null>("initial");
264
+
265
+ effect(({ ready }) => {
266
+ const id = ready(id$);
267
+ effectFn(id);
268
+ });
269
+
270
+ await new Promise((r) => setTimeout(r, 0));
271
+ expect(effectFn).toHaveBeenCalledWith("initial");
272
+ expect(effectFn).toHaveBeenCalledTimes(1);
273
+
274
+ // Set to null - effect should not run
275
+ id$.set(null);
276
+ await new Promise((r) => setTimeout(r, 10));
277
+ expect(effectFn).toHaveBeenCalledTimes(1); // Still 1
278
+
279
+ // Set back to non-null
280
+ id$.set("new-value");
281
+ await new Promise((r) => setTimeout(r, 10));
282
+ expect(effectFn).toHaveBeenCalledWith("new-value");
283
+ expect(effectFn).toHaveBeenCalledTimes(2);
284
+ });
285
+
286
+ it("should support ready() with selector", async () => {
287
+ const effectFn = vi.fn();
288
+ const user$ = atom<{ id: number; email: string | null }>({
289
+ id: 1,
290
+ email: null,
291
+ });
292
+
293
+ effect(({ ready }) => {
294
+ const email = ready(user$, (u) => u.email);
295
+ effectFn(email);
296
+ });
297
+
298
+ await new Promise((r) => setTimeout(r, 0));
299
+ expect(effectFn).not.toHaveBeenCalled();
300
+
301
+ // Set email
302
+ user$.set({ id: 1, email: "test@example.com" });
303
+ await new Promise((r) => setTimeout(r, 10));
304
+ expect(effectFn).toHaveBeenCalledWith("test@example.com");
305
+ });
306
+
307
+ it("should run cleanup when transitioning from non-null to null", async () => {
308
+ const cleanupFn = vi.fn();
309
+ const effectFn = vi.fn();
310
+ const id$ = atom<string | null>("initial");
311
+
312
+ effect(({ ready, onCleanup }) => {
313
+ const id = ready(id$);
314
+ effectFn(id);
315
+ onCleanup(cleanupFn);
316
+ });
317
+
318
+ await new Promise((r) => setTimeout(r, 0));
319
+ expect(effectFn).toHaveBeenCalledWith("initial");
320
+ expect(cleanupFn).not.toHaveBeenCalled();
321
+
322
+ // Set to null - should trigger cleanup from previous run
323
+ id$.set(null);
324
+ await new Promise((r) => setTimeout(r, 10));
325
+ expect(cleanupFn).toHaveBeenCalledTimes(1);
326
+ });
327
+
328
+ it("should work with multiple ready() calls", async () => {
329
+ const effectFn = vi.fn();
330
+ const firstName$ = atom<string | null>(null);
331
+ const lastName$ = atom<string | null>(null);
332
+
333
+ effect(({ ready }) => {
334
+ const first = ready(firstName$);
335
+ const last = ready(lastName$);
336
+ effectFn(`${first} ${last}`);
337
+ });
338
+
339
+ await new Promise((r) => setTimeout(r, 0));
340
+ expect(effectFn).not.toHaveBeenCalled();
341
+
342
+ // Set only firstName - still suspended
343
+ firstName$.set("John");
344
+ await new Promise((r) => setTimeout(r, 10));
345
+ expect(effectFn).not.toHaveBeenCalled();
346
+
347
+ // Set lastName - effect should run
348
+ lastName$.set("Doe");
349
+ await new Promise((r) => setTimeout(r, 10));
350
+ expect(effectFn).toHaveBeenCalledWith("John Doe");
351
+ });
352
+
353
+ it("should allow mixing ready() with read()", async () => {
354
+ const effectFn = vi.fn();
355
+ const requiredId$ = atom<string | null>(null);
356
+ const optionalLabel$ = atom("default");
357
+
358
+ effect(({ ready, read }) => {
359
+ const id = ready(requiredId$);
360
+ const label = read(optionalLabel$);
361
+ effectFn({ id, label });
362
+ });
363
+
364
+ await new Promise((r) => setTimeout(r, 0));
365
+ expect(effectFn).not.toHaveBeenCalled();
366
+
367
+ // Set required value
368
+ requiredId$.set("123");
369
+ await new Promise((r) => setTimeout(r, 10));
370
+ expect(effectFn).toHaveBeenCalledWith({ id: "123", label: "default" });
371
+
372
+ // Change optional value
373
+ effectFn.mockClear();
374
+ optionalLabel$.set("custom");
375
+ await new Promise((r) => setTimeout(r, 10));
376
+ expect(effectFn).toHaveBeenCalledWith({ id: "123", label: "custom" });
377
+ });
378
+
379
+ it("should handle real-world: sync to localStorage only when user is logged in", async () => {
380
+ const mockStorage: Record<string, string> = {};
381
+ const currentUser$ = atom<{ id: string } | null>(null);
382
+ const preferences$ = atom({ theme: "dark" });
383
+
384
+ effect(({ ready, read, onCleanup }) => {
385
+ const user = ready(currentUser$);
386
+ const prefs = read(preferences$);
387
+
388
+ // Sync preferences to localStorage for logged-in user
389
+ mockStorage[`prefs:${user.id}`] = JSON.stringify(prefs);
390
+
391
+ onCleanup(() => {
392
+ delete mockStorage[`prefs:${user.id}`];
393
+ });
394
+ });
395
+
396
+ await new Promise((r) => setTimeout(r, 0));
397
+ // No user logged in - nothing in storage
398
+ expect(Object.keys(mockStorage)).toHaveLength(0);
399
+
400
+ // User logs in
401
+ currentUser$.set({ id: "u1" });
402
+ await new Promise((r) => setTimeout(r, 10));
403
+ expect(mockStorage["prefs:u1"]).toBe('{"theme":"dark"}');
404
+
405
+ // Preferences change
406
+ preferences$.set({ theme: "light" });
407
+ await new Promise((r) => setTimeout(r, 10));
408
+ expect(mockStorage["prefs:u1"]).toBe('{"theme":"light"}');
409
+
410
+ // User logs out - cleanup runs
411
+ currentUser$.set(null);
412
+ await new Promise((r) => setTimeout(r, 10));
413
+ expect(mockStorage["prefs:u1"]).toBeUndefined();
414
+ });
415
+ });
196
416
  });
@@ -1,15 +1,15 @@
1
1
  import { batch } from "./batch";
2
2
  import { derived } from "./derived";
3
3
  import { emitter } from "./emitter";
4
- import { isPromiseLike } from "./isPromiseLike";
5
- import { SelectContext } from "./select";
4
+ import { ReactiveSelector, SelectContext } from "./select";
6
5
  import { EffectOptions } from "./types";
6
+ import { WithReadySelectContext } from "./withReady";
7
7
 
8
8
  /**
9
9
  * Context object passed to effect functions.
10
- * Extends `SelectContext` with cleanup and error handling utilities.
10
+ * Extends `SelectContext` with cleanup utilities.
11
11
  */
12
- export interface EffectContext extends SelectContext {
12
+ export interface EffectContext extends SelectContext, WithReadySelectContext {
13
13
  /**
14
14
  * Register a cleanup function that runs before the next execution or on dispose.
15
15
  * Multiple cleanup functions can be registered; they run in FIFO order.
@@ -18,44 +18,22 @@ export interface EffectContext extends SelectContext {
18
18
  *
19
19
  * @example
20
20
  * ```ts
21
- * effect(({ get, onCleanup }) => {
21
+ * effect(({ read, onCleanup }) => {
22
22
  * const id = setInterval(() => console.log('tick'), 1000);
23
23
  * onCleanup(() => clearInterval(id));
24
24
  * });
25
25
  * ```
26
26
  */
27
27
  onCleanup: (cleanup: VoidFunction) => void;
28
-
29
- /**
30
- * Register an error handler for synchronous errors thrown in the effect.
31
- * If registered, prevents errors from propagating to `options.onError`.
32
- *
33
- * @param handler - Function to handle errors
34
- *
35
- * @example
36
- * ```ts
37
- * effect(({ get, onError }) => {
38
- * onError((e) => console.error('Effect failed:', e));
39
- * riskyOperation();
40
- * });
41
- * ```
42
- */
43
- onError: (handler: (error: unknown) => void) => void;
44
28
  }
45
29
 
46
- /**
47
- * Callback function for effects.
48
- * Receives the effect context with `{ get, all, any, race, settled, onCleanup, onError }` utilities.
49
- */
50
- export type EffectFn = (context: EffectContext) => void;
51
-
52
30
  /**
53
31
  * Creates a side-effect that runs when accessed atom(s) change.
54
32
  *
55
33
  * Effects are similar to derived atoms but for side-effects rather than computed values.
56
34
  * They inherit derived's behavior:
57
35
  * - **Suspense-like async**: Waits for async atoms to resolve before running
58
- * - **Conditional dependencies**: Only tracks atoms actually accessed via `get()`
36
+ * - **Conditional dependencies**: Only tracks atoms actually accessed via `read()`
59
37
  * - **Automatic cleanup**: Previous cleanup runs before next execution
60
38
  * - **Batched updates**: Atom updates within the effect are batched
61
39
  *
@@ -65,23 +43,23 @@ export type EffectFn = (context: EffectContext) => void;
65
43
  *
66
44
  * ```ts
67
45
  * // ❌ WRONG - Don't use async function
68
- * effect(async ({ get }) => {
46
+ * effect(async ({ read }) => {
69
47
  * const data = await fetch('/api');
70
48
  * console.log(data);
71
49
  * });
72
50
  *
73
- * // ✅ CORRECT - Create async atom and read with get()
51
+ * // ✅ CORRECT - Create async atom and read with read()
74
52
  * const data$ = atom(fetch('/api').then(r => r.json()));
75
- * effect(({ get }) => {
76
- * console.log(get(data$)); // Suspends until resolved
53
+ * effect(({ read }) => {
54
+ * console.log(read(data$)); // Suspends until resolved
77
55
  * });
78
56
  * ```
79
57
  *
80
58
  * ## Basic Usage
81
59
  *
82
60
  * ```ts
83
- * const dispose = effect(({ get }) => {
84
- * localStorage.setItem('count', String(get(countAtom)));
61
+ * const dispose = effect(({ read }) => {
62
+ * localStorage.setItem('count', String(read(countAtom)));
85
63
  * });
86
64
  * ```
87
65
  *
@@ -90,85 +68,85 @@ export type EffectFn = (context: EffectContext) => void;
90
68
  * Use `onCleanup` to register cleanup functions that run before the next execution or on dispose:
91
69
  *
92
70
  * ```ts
93
- * const dispose = effect(({ get, onCleanup }) => {
94
- * const interval = get(intervalAtom);
71
+ * const dispose = effect(({ read, onCleanup }) => {
72
+ * const interval = read(intervalAtom);
95
73
  * const id = setInterval(() => console.log('tick'), interval);
96
74
  * onCleanup(() => clearInterval(id));
97
75
  * });
98
76
  * ```
99
77
  *
100
- * ## Error Handling
78
+ * ## IMPORTANT: Do NOT Use try/catch - Use safe() Instead
101
79
  *
102
- * Use `onError` callback to handle errors within the effect, or `options.onError` for unhandled errors:
80
+ * **Never wrap `read()` calls in try/catch blocks.** The `read()` function throws
81
+ * Promises when atoms are loading (Suspense pattern). A try/catch will catch
82
+ * these Promises and break the Suspense mechanism.
103
83
  *
104
84
  * ```ts
105
- * // Callback-based error handling
106
- * const dispose = effect(({ get, onError }) => {
107
- * onError((e) => console.error('Effect failed:', e));
108
- * const data = get(dataAtom);
109
- * riskyOperation(data);
85
+ * // ❌ WRONG - Catches Suspense Promise, breaks loading state
86
+ * effect(({ read }) => {
87
+ * try {
88
+ * const data = read(asyncAtom$);
89
+ * riskyOperation(data);
90
+ * } catch (e) {
91
+ * console.error(e); // Catches BOTH errors AND loading promises!
92
+ * }
110
93
  * });
111
94
  *
112
- * // Option-based error handling (for unhandled errors)
113
- * const dispose = effect(
114
- * ({ get }) => {
115
- * const data = get(dataAtom);
116
- * riskyOperation(data);
117
- * },
118
- * { onError: (e) => console.error('Effect failed:', e) }
119
- * );
95
+ * // ✅ CORRECT - Use safe() to catch errors but preserve Suspense
96
+ * effect(({ read, safe }) => {
97
+ * const [err, data] = safe(() => {
98
+ * const raw = read(asyncAtom$); // Can throw Promise (Suspense)
99
+ * return riskyOperation(raw); // Can throw Error
100
+ * });
101
+ *
102
+ * if (err) {
103
+ * console.error('Operation failed:', err);
104
+ * return;
105
+ * }
106
+ * // Use data safely
107
+ * });
120
108
  * ```
121
109
  *
122
- * @param fn - Effect callback receiving context with `{ get, all, any, race, settled, onCleanup, onError }`.
110
+ * The `safe()` utility:
111
+ * - **Catches errors** and returns `[error, undefined]`
112
+ * - **Re-throws Promises** to preserve Suspense behavior
113
+ * - Returns `[undefined, result]` on success
114
+ *
115
+ * @param fn - Effect callback receiving context with `{ read, all, any, race, settled, safe, onCleanup }`.
123
116
  * Must be synchronous (not async).
124
- * @param options - Optional configuration (key, onError for unhandled errors)
117
+ * @param options - Optional configuration (key)
125
118
  * @returns Dispose function to stop the effect and run final cleanup
126
119
  * @throws Error if effect function returns a Promise
127
120
  */
128
- export function effect(fn: EffectFn, options?: EffectOptions): VoidFunction {
121
+ export function effect(
122
+ fn: ReactiveSelector<void, EffectContext>,
123
+ _options?: EffectOptions
124
+ ): VoidFunction {
129
125
  let disposed = false;
130
126
  const cleanupEmitter = emitter();
131
- const errorEmitter = emitter<unknown>();
132
127
 
133
128
  // Create a derived atom that runs the effect on each recomputation.
134
129
  const derivedAtom = derived((context) => {
135
130
  // Run previous cleanup before next execution
136
- errorEmitter.clear();
137
131
  cleanupEmitter.emitAndClear();
138
132
 
139
133
  // Skip effect execution if disposed
140
134
  if (disposed) return;
141
135
 
142
- try {
143
- // Run effect in a batch - multiple atom updates will only notify once
144
- batch(() =>
145
- fn({
146
- ...context,
147
- onCleanup: cleanupEmitter.on,
148
- onError: errorEmitter.on,
149
- })
150
- );
151
- } catch (error) {
152
- if (isPromiseLike(error)) {
153
- // let derived atom handle the promise
154
- throw error;
155
- }
156
- // Emit to registered handlers, or fall back to options.onError
157
- if (errorEmitter.size() > 0) {
158
- errorEmitter.emitAndClear(error);
159
- } else if (options?.onError && error instanceof Error) {
160
- options.onError(error);
161
- }
162
- }
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));
163
144
  });
164
145
 
165
- // Access .value to trigger initial computation (derived is lazy)
166
- // Handle the promise
167
- derivedAtom.value.catch((error) => {
168
- if (options?.onError && error instanceof Error) {
169
- options.onError(error);
170
- }
171
- // Silently ignore if no error handler
146
+ // 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
172
150
  });
173
151
 
174
152
  return () => {
@@ -177,7 +155,6 @@ export function effect(fn: EffectFn, options?: EffectOptions): VoidFunction {
177
155
 
178
156
  // Mark as disposed
179
157
  disposed = true;
180
- errorEmitter.clear();
181
158
  // Run final cleanup
182
159
  cleanupEmitter.emitAndClear();
183
160
  };
@@ -0,0 +1,69 @@
1
+ import { isDerived } from "./isAtom";
2
+ import { isPromiseLike } from "./isPromiseLike";
3
+ import { trackPromise } from "./promiseCache";
4
+ import { Atom, AtomState, DerivedAtom } from "./types";
5
+
6
+ /**
7
+ * Returns the current state of an atom as a discriminated union.
8
+ *
9
+ * For any atom (mutable or derived):
10
+ * - If value is not a Promise: returns ready state
11
+ * - If value is a Promise: tracks and returns its state (ready/error/loading)
12
+ *
13
+ * @param atom - The atom to get state from
14
+ * @returns AtomState discriminated union (ready | error | loading)
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * const state = getAtomState(myAtom$);
19
+ *
20
+ * switch (state.status) {
21
+ * case "ready":
22
+ * console.log(state.value); // T
23
+ * break;
24
+ * case "error":
25
+ * console.log(state.error);
26
+ * break;
27
+ * case "loading":
28
+ * console.log(state.promise);
29
+ * break;
30
+ * }
31
+ * ```
32
+ */
33
+ export function getAtomState<T>(atom: Atom<T>): AtomState<Awaited<T>> {
34
+ if (isDerived(atom)) {
35
+ return (atom as DerivedAtom<Awaited<T>>).state();
36
+ }
37
+ const value = atom.get();
38
+
39
+ // 1. Sync value - ready
40
+ if (!isPromiseLike(value)) {
41
+ return {
42
+ status: "ready",
43
+ value: value as Awaited<T>,
44
+ };
45
+ }
46
+
47
+ // 2. Promise value - check state via promiseCache
48
+ const state = trackPromise(value);
49
+
50
+ switch (state.status) {
51
+ case "fulfilled":
52
+ return {
53
+ status: "ready",
54
+ value: state.value as Awaited<T>,
55
+ };
56
+
57
+ case "rejected":
58
+ return {
59
+ status: "error",
60
+ error: state.error,
61
+ };
62
+
63
+ case "pending":
64
+ return {
65
+ status: "loading",
66
+ promise: state.promise as Promise<Awaited<T>>,
67
+ };
68
+ }
69
+ }