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,8 +1,9 @@
1
- import { onCreateHook } from "./onCreateHook";
1
+ import { CreateInfo, DerivedInfo, onCreateHook } from "./onCreateHook";
2
2
  import { emitter } from "./emitter";
3
3
  import { resolveEquality } from "./equality";
4
+ import { onErrorHook } from "./onErrorHook";
4
5
  import { scheduleNotifyHook } from "./scheduleNotifyHook";
5
- import { select, SelectContext } from "./select";
6
+ import { ReactiveSelector, select, SelectContext } from "./select";
6
7
  import {
7
8
  Atom,
8
9
  AtomState,
@@ -12,21 +13,35 @@ import {
12
13
  SYMBOL_ATOM,
13
14
  SYMBOL_DERIVED,
14
15
  } from "./types";
16
+ import { withReady, WithReadySelectContext } from "./withReady";
17
+
18
+ /**
19
+ * Internal options for derived atoms.
20
+ * These are not part of the public API.
21
+ * @internal
22
+ */
23
+ export interface DerivedInternalOptions {
24
+ /**
25
+ * Override the error source for onErrorHook.
26
+ * Used by effect() to attribute errors to the effect instead of the internal derived.
27
+ */
28
+ _errorSource?: CreateInfo;
29
+ }
15
30
 
16
31
  /**
17
32
  * Context object passed to derived atom selector functions.
18
- * Provides utilities for reading atoms: `{ get, all, any, race, settled }`.
33
+ * Provides utilities for reading atoms: `{ read, all, any, race, settled }`.
19
34
  *
20
35
  * Currently identical to `SelectContext`, but defined separately to allow
21
36
  * future derived-specific extensions without breaking changes.
22
37
  */
23
- export interface DerivedContext extends SelectContext {}
38
+ export interface DerivedContext extends SelectContext, WithReadySelectContext {}
24
39
 
25
40
  /**
26
41
  * Creates a derived (computed) atom from source atom(s).
27
42
  *
28
43
  * Derived atoms are **read-only** and automatically recompute when their
29
- * source atoms change. The `.value` property always returns a `Promise<T>`,
44
+ * source atoms change. The `.get()` method always returns a `Promise<T>`,
30
45
  * even for synchronous computations.
31
46
  *
32
47
  * ## IMPORTANT: Selector Must Return Synchronous Value
@@ -35,35 +50,68 @@ export interface DerivedContext extends SelectContext {}
35
50
  *
36
51
  * ```ts
37
52
  * // ❌ WRONG - Don't use async function
38
- * derived(async ({ get }) => {
53
+ * derived(async ({ read }) => {
39
54
  * const data = await fetch('/api');
40
55
  * return data;
41
56
  * });
42
57
  *
43
58
  * // ❌ WRONG - Don't return a Promise
44
- * derived(({ get }) => fetch('/api').then(r => r.json()));
59
+ * derived(({ read }) => fetch('/api').then(r => r.json()));
45
60
  *
46
- * // ✅ CORRECT - Create async atom and read with get()
61
+ * // ✅ CORRECT - Create async atom and read with read()
47
62
  * const data$ = atom(fetch('/api').then(r => r.json()));
48
- * derived(({ get }) => get(data$)); // Suspends until resolved
63
+ * derived(({ read }) => read(data$)); // Suspends until resolved
49
64
  * ```
50
65
  *
66
+ * ## IMPORTANT: Do NOT Use try/catch - Use safe() Instead
67
+ *
68
+ * **Never wrap `read()` calls in try/catch blocks.** The `read()` function throws
69
+ * Promises when atoms are loading (Suspense pattern). A try/catch will catch
70
+ * these Promises and break the Suspense mechanism.
71
+ *
72
+ * ```ts
73
+ * // ❌ WRONG - Catches Suspense Promise, breaks loading state
74
+ * derived(({ read }) => {
75
+ * try {
76
+ * return read(asyncAtom$);
77
+ * } catch (e) {
78
+ * return 'fallback'; // This catches BOTH errors AND loading promises!
79
+ * }
80
+ * });
81
+ *
82
+ * // ✅ CORRECT - Use safe() to catch errors but preserve Suspense
83
+ * derived(({ read, safe }) => {
84
+ * const [err, data] = safe(() => {
85
+ * const raw = read(asyncAtom$); // Can throw Promise (Suspense)
86
+ * return JSON.parse(raw); // Can throw Error
87
+ * });
88
+ *
89
+ * if (err) return { error: err.message };
90
+ * return { data };
91
+ * });
92
+ * ```
93
+ *
94
+ * The `safe()` utility:
95
+ * - **Catches errors** and returns `[error, undefined]`
96
+ * - **Re-throws Promises** to preserve Suspense behavior
97
+ * - Returns `[undefined, result]` on success
98
+ *
51
99
  * ## Key Features
52
100
  *
53
- * 1. **Always async**: `.value` returns `Promise<T>`
101
+ * 1. **Always async**: `.get()` returns `Promise<T>`
54
102
  * 2. **Lazy computation**: Value is computed on first access
55
103
  * 3. **Automatic updates**: Recomputes when any source atom changes
56
104
  * 4. **Equality checking**: Only notifies if derived value changed
57
105
  * 5. **Fallback support**: Optional fallback for loading/error states
58
- * 6. **Suspense-like async**: `get()` throws promise if loading
106
+ * 6. **Suspense-like async**: `read()` throws promise if loading
59
107
  * 7. **Conditional dependencies**: Only subscribes to atoms accessed
60
108
  *
61
- * ## Suspense-Style get()
109
+ * ## Suspense-Style read()
62
110
  *
63
- * The `get()` function behaves like React Suspense:
64
- * - If source atom is **loading**: `get()` throws the promise
65
- * - If source atom has **error**: `get()` throws the error
66
- * - If source atom has **value**: `get()` returns the value
111
+ * The `read()` function behaves like React Suspense:
112
+ * - If source atom is **loading**: `read()` throws the promise
113
+ * - If source atom has **error**: `read()` throws the error
114
+ * - If source atom has **value**: `read()` returns the value
67
115
  *
68
116
  * @template T - Derived value type
69
117
  * @template F - Whether fallback is provided
@@ -75,9 +123,9 @@ export interface DerivedContext extends SelectContext {}
75
123
  * @example Basic derived (no fallback)
76
124
  * ```ts
77
125
  * const count$ = atom(5);
78
- * const doubled$ = derived(({ get }) => get(count$) * 2);
126
+ * const doubled$ = derived(({ read }) => read(count$) * 2);
79
127
  *
80
- * await doubled$.value; // 10
128
+ * await doubled$.get(); // 10
81
129
  * doubled$.staleValue; // undefined (until first resolve) -> 10
82
130
  * doubled$.state(); // { status: "ready", value: 10 }
83
131
  * ```
@@ -85,7 +133,7 @@ export interface DerivedContext extends SelectContext {}
85
133
  * @example With fallback
86
134
  * ```ts
87
135
  * const posts$ = atom(fetchPosts());
88
- * const count$ = derived(({ get }) => get(posts$).length, { fallback: 0 });
136
+ * const count$ = derived(({ read }) => read(posts$).length, { fallback: 0 });
89
137
  *
90
138
  * count$.staleValue; // 0 (during loading) -> 42 (after resolve)
91
139
  * count$.state(); // { status: "loading", promise } during loading
@@ -105,27 +153,27 @@ export interface DerivedContext extends SelectContext {}
105
153
  *
106
154
  * @example Refresh
107
155
  * ```ts
108
- * const data$ = derived(({ get }) => get(source$));
156
+ * const data$ = derived(({ read }) => read(source$));
109
157
  * data$.refresh(); // Re-run computation
110
158
  * ```
111
159
  */
112
160
 
113
161
  // Overload: Without fallback - staleValue is T | undefined
114
162
  export function derived<T>(
115
- fn: (ctx: DerivedContext) => T,
116
- options?: DerivedOptions<T>
163
+ fn: ReactiveSelector<T, DerivedContext>,
164
+ options?: DerivedOptions<T> & DerivedInternalOptions
117
165
  ): DerivedAtom<T, false>;
118
166
 
119
167
  // Overload: With fallback - staleValue is guaranteed T
120
168
  export function derived<T>(
121
- fn: (ctx: DerivedContext) => T,
122
- options: DerivedOptions<T> & { fallback: T }
169
+ fn: ReactiveSelector<T, DerivedContext>,
170
+ options: DerivedOptions<T> & { fallback: T } & DerivedInternalOptions
123
171
  ): DerivedAtom<T, true>;
124
172
 
125
173
  // Implementation
126
174
  export function derived<T>(
127
- fn: (ctx: DerivedContext) => T,
128
- options: DerivedOptions<T> & { fallback?: T } = {}
175
+ fn: ReactiveSelector<T, DerivedContext>,
176
+ options: DerivedOptions<T> & { fallback?: T } & DerivedInternalOptions = {}
129
177
  ): DerivedAtom<T, boolean> {
130
178
  const changeEmitter = emitter();
131
179
  const eq = resolveEquality(options.equals as Equality<unknown>);
@@ -142,9 +190,30 @@ export function derived<T>(
142
190
  let isLoading = false;
143
191
  let version = 0;
144
192
 
193
+ // Store resolve/reject to allow reusing the same promise across recomputations
194
+ let resolvePromise: ((value: T) => void) | null = null;
195
+ let rejectPromise: ((error: unknown) => void) | null = null;
196
+
145
197
  // Track current subscriptions (atom -> unsubscribe function)
146
198
  const subscriptions = new Map<Atom<unknown>, VoidFunction>();
147
199
 
200
+ // CreateInfo for this derived - stored for onErrorHook
201
+ // Will be set after derivedAtom is created
202
+ let createInfo: DerivedInfo;
203
+
204
+ /**
205
+ * Handles errors by calling both the user's onError callback and the global onErrorHook.
206
+ */
207
+ const handleError = (error: unknown) => {
208
+ // Invoke user's error callback if provided
209
+ options.onError?.(error);
210
+
211
+ // Invoke global error hook
212
+ // Use _errorSource if provided (for effect), otherwise use this derived's createInfo
213
+ const source = options._errorSource ?? createInfo;
214
+ onErrorHook.current?.({ source, error });
215
+ };
216
+
148
217
  /**
149
218
  * Schedules notification to all subscribers.
150
219
  */
@@ -179,65 +248,104 @@ export function derived<T>(
179
248
 
180
249
  /**
181
250
  * Computes the derived value.
182
- * Creates a new Promise that resolves when the computation completes.
251
+ * Reuses the existing Promise if loading (to prevent orphaned promises
252
+ * that React Suspense might be waiting on).
183
253
  */
184
254
  const compute = (silent = false) => {
185
255
  const computeVersion = ++version;
186
256
  isLoading = true;
187
257
  lastError = undefined; // Clear error when starting new computation
188
258
 
189
- // Create a new promise for this computation
190
- currentPromise = new Promise<T>((resolve, reject) => {
191
- // Run select to compute value and track dependencies
192
- const attemptCompute = () => {
193
- const result = select(fn);
194
-
195
- // Update subscriptions based on accessed deps
196
- updateSubscriptions(result.dependencies);
197
-
198
- if (result.promise) {
199
- // Promise thrown - wait for it and retry
200
- result.promise.then(
201
- () => {
202
- // Check if we're still the current computation
203
- if (version !== computeVersion) return;
204
- attemptCompute();
205
- },
206
- (error) => {
207
- // Check if we're still the current computation
208
- if (version !== computeVersion) return;
209
- isLoading = false;
210
- lastError = error;
211
- reject(error);
212
- if (!silent) notify();
213
- }
214
- );
215
- } else if (result.error !== undefined) {
216
- // Error thrown
217
- isLoading = false;
218
- lastError = result.error;
219
- reject(result.error);
220
- if (!silent) notify();
221
- } else {
222
- // Success - update lastResolved and resolve
223
- const newValue = result.value as T;
224
- isLoading = false;
225
- lastError = undefined;
226
-
227
- // Only update and notify if value changed
228
- if (!lastResolved || !eq(newValue, lastResolved.value)) {
229
- lastResolved = { value: newValue };
230
- if (!silent) notify();
231
- }
259
+ // Create a new promise if:
260
+ // 1. We don't have one yet, OR
261
+ // 2. The previous computation completed (resolved/rejected) and we need a new one
262
+ // This ensures we reuse promises while loading (for Suspense) but create fresh
263
+ // promises for new computations after completion
264
+ if (!resolvePromise) {
265
+ currentPromise = new Promise<T>((resolve, reject) => {
266
+ resolvePromise = resolve;
267
+ rejectPromise = reject;
268
+ });
269
+ // Prevent unhandled rejection warnings - errors are accessible via:
270
+ // 1. onError callback (if provided)
271
+ // 2. state() returning { status: "error", error }
272
+ // 3. .get().catch() by consumers
273
+ currentPromise.catch(() => {});
274
+ }
232
275
 
233
- resolve(newValue);
276
+ // Run select to compute value and track dependencies
277
+ const attemptCompute = () => {
278
+ const result = select((context) => fn(context.use(withReady())));
279
+
280
+ // Update subscriptions based on accessed deps
281
+ updateSubscriptions(result.dependencies);
282
+
283
+ if (result.promise) {
284
+ // Notify subscribers that we're now in loading state
285
+ // This allows downstream derived atoms and useSelector to suspend
286
+ if (!silent) notify();
287
+ // Promise thrown - wait for it and retry
288
+ // Note: For never-resolving promises (from ready()), this .then() will never fire.
289
+ // But when a dependency changes, compute() is called again via subscription,
290
+ // and the new computation will run (with a new version).
291
+ result.promise.then(
292
+ () => {
293
+ // Check if we're still the current computation
294
+ if (version !== computeVersion) return;
295
+ attemptCompute();
296
+ },
297
+ (error) => {
298
+ // Check if we're still the current computation
299
+ if (version !== computeVersion) return;
300
+ isLoading = false;
301
+ lastError = error;
302
+ rejectPromise?.(error);
303
+ // Clear resolve/reject so next computation creates new promise
304
+ resolvePromise = null;
305
+ rejectPromise = null;
306
+ // Invoke error handlers
307
+ handleError(error);
308
+ // Always notify when promise rejects - subscribers need to know
309
+ // state changed from loading to error
310
+ notify();
311
+ }
312
+ );
313
+ } else if (result.error !== undefined) {
314
+ // Error thrown
315
+ isLoading = false;
316
+ lastError = result.error;
317
+ rejectPromise?.(result.error);
318
+ // Clear resolve/reject so next computation creates new promise
319
+ resolvePromise = null;
320
+ rejectPromise = null;
321
+ // Invoke error handlers
322
+ handleError(result.error);
323
+ if (!silent) notify();
324
+ } else {
325
+ // Success - update lastResolved and resolve
326
+ const newValue = result.value as T;
327
+ const wasFirstResolve = !lastResolved;
328
+ isLoading = false;
329
+ lastError = undefined;
330
+
331
+ // Only update and notify if value changed
332
+ if (!lastResolved || !eq(newValue, lastResolved.value)) {
333
+ lastResolved = { value: newValue };
334
+ // Always notify on first resolve (loading → ready transition)
335
+ // even if silent, because subscribers need to know state changed
336
+ if (wasFirstResolve || !silent) notify();
234
337
  }
235
- };
236
338
 
237
- attemptCompute();
238
- });
339
+ resolvePromise?.(newValue);
340
+ // Clear resolve/reject so next computation creates new promise
341
+ resolvePromise = null;
342
+ rejectPromise = null;
343
+ }
344
+ };
345
+
346
+ attemptCompute();
239
347
 
240
- return currentPromise;
348
+ return currentPromise!;
241
349
  };
242
350
 
243
351
  /**
@@ -258,10 +366,10 @@ export function derived<T>(
258
366
  meta: options.meta,
259
367
 
260
368
  /**
261
- * The computed value as a Promise.
369
+ * Get the computed value as a Promise.
262
370
  * Always returns Promise<T>, even for sync computations.
263
371
  */
264
- get value(): Promise<T> {
372
+ get(): Promise<T> {
265
373
  init();
266
374
  return currentPromise!;
267
375
  },
@@ -327,13 +435,16 @@ export function derived<T>(
327
435
  },
328
436
  };
329
437
 
330
- // Notify devtools/plugins of derived atom creation
331
- onCreateHook.current?.({
438
+ // Store createInfo for use in onErrorHook
439
+ createInfo = {
332
440
  type: "derived",
333
441
  key: options.meta?.key,
334
442
  meta: options.meta,
335
- atom: derivedAtom,
336
- });
443
+ instance: derivedAtom,
444
+ };
445
+
446
+ // Notify devtools/plugins of derived atom creation
447
+ onCreateHook.current?.(createInfo);
337
448
 
338
449
  return derivedAtom;
339
450
  }