atomirx 0.0.1 → 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 +867 -160
  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 +17 -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
@@ -2,7 +2,7 @@ import { onCreateHook } from "./onCreateHook";
2
2
  import { emitter } from "./emitter";
3
3
  import { resolveEquality } from "./equality";
4
4
  import { scheduleNotifyHook } from "./scheduleNotifyHook";
5
- import { select, SelectContext } from "./select";
5
+ import { ReactiveSelector, select, SelectContext } from "./select";
6
6
  import {
7
7
  Atom,
8
8
  AtomState,
@@ -12,21 +12,22 @@ import {
12
12
  SYMBOL_ATOM,
13
13
  SYMBOL_DERIVED,
14
14
  } from "./types";
15
+ import { withReady, WithReadySelectContext } from "./withReady";
15
16
 
16
17
  /**
17
18
  * Context object passed to derived atom selector functions.
18
- * Provides utilities for reading atoms: `{ get, all, any, race, settled }`.
19
+ * Provides utilities for reading atoms: `{ read, all, any, race, settled }`.
19
20
  *
20
21
  * Currently identical to `SelectContext`, but defined separately to allow
21
22
  * future derived-specific extensions without breaking changes.
22
23
  */
23
- export interface DerivedContext extends SelectContext {}
24
+ export interface DerivedContext extends SelectContext, WithReadySelectContext {}
24
25
 
25
26
  /**
26
27
  * Creates a derived (computed) atom from source atom(s).
27
28
  *
28
29
  * Derived atoms are **read-only** and automatically recompute when their
29
- * source atoms change. The `.value` property always returns a `Promise<T>`,
30
+ * source atoms change. The `.get()` method always returns a `Promise<T>`,
30
31
  * even for synchronous computations.
31
32
  *
32
33
  * ## IMPORTANT: Selector Must Return Synchronous Value
@@ -35,35 +36,68 @@ export interface DerivedContext extends SelectContext {}
35
36
  *
36
37
  * ```ts
37
38
  * // ❌ WRONG - Don't use async function
38
- * derived(async ({ get }) => {
39
+ * derived(async ({ read }) => {
39
40
  * const data = await fetch('/api');
40
41
  * return data;
41
42
  * });
42
43
  *
43
44
  * // ❌ WRONG - Don't return a Promise
44
- * derived(({ get }) => fetch('/api').then(r => r.json()));
45
+ * derived(({ read }) => fetch('/api').then(r => r.json()));
45
46
  *
46
- * // ✅ CORRECT - Create async atom and read with get()
47
+ * // ✅ CORRECT - Create async atom and read with read()
47
48
  * const data$ = atom(fetch('/api').then(r => r.json()));
48
- * derived(({ get }) => get(data$)); // Suspends until resolved
49
+ * derived(({ read }) => read(data$)); // Suspends until resolved
49
50
  * ```
50
51
  *
52
+ * ## IMPORTANT: Do NOT Use try/catch - Use safe() Instead
53
+ *
54
+ * **Never wrap `read()` calls in try/catch blocks.** The `read()` function throws
55
+ * Promises when atoms are loading (Suspense pattern). A try/catch will catch
56
+ * these Promises and break the Suspense mechanism.
57
+ *
58
+ * ```ts
59
+ * // ❌ WRONG - Catches Suspense Promise, breaks loading state
60
+ * derived(({ read }) => {
61
+ * try {
62
+ * return read(asyncAtom$);
63
+ * } catch (e) {
64
+ * return 'fallback'; // This catches BOTH errors AND loading promises!
65
+ * }
66
+ * });
67
+ *
68
+ * // ✅ CORRECT - Use safe() to catch errors but preserve Suspense
69
+ * derived(({ read, safe }) => {
70
+ * const [err, data] = safe(() => {
71
+ * const raw = read(asyncAtom$); // Can throw Promise (Suspense)
72
+ * return JSON.parse(raw); // Can throw Error
73
+ * });
74
+ *
75
+ * if (err) return { error: err.message };
76
+ * return { data };
77
+ * });
78
+ * ```
79
+ *
80
+ * The `safe()` utility:
81
+ * - **Catches errors** and returns `[error, undefined]`
82
+ * - **Re-throws Promises** to preserve Suspense behavior
83
+ * - Returns `[undefined, result]` on success
84
+ *
51
85
  * ## Key Features
52
86
  *
53
- * 1. **Always async**: `.value` returns `Promise<T>`
87
+ * 1. **Always async**: `.get()` returns `Promise<T>`
54
88
  * 2. **Lazy computation**: Value is computed on first access
55
89
  * 3. **Automatic updates**: Recomputes when any source atom changes
56
90
  * 4. **Equality checking**: Only notifies if derived value changed
57
91
  * 5. **Fallback support**: Optional fallback for loading/error states
58
- * 6. **Suspense-like async**: `get()` throws promise if loading
92
+ * 6. **Suspense-like async**: `read()` throws promise if loading
59
93
  * 7. **Conditional dependencies**: Only subscribes to atoms accessed
60
94
  *
61
- * ## Suspense-Style get()
95
+ * ## Suspense-Style read()
62
96
  *
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
97
+ * The `read()` function behaves like React Suspense:
98
+ * - If source atom is **loading**: `read()` throws the promise
99
+ * - If source atom has **error**: `read()` throws the error
100
+ * - If source atom has **value**: `read()` returns the value
67
101
  *
68
102
  * @template T - Derived value type
69
103
  * @template F - Whether fallback is provided
@@ -75,9 +109,9 @@ export interface DerivedContext extends SelectContext {}
75
109
  * @example Basic derived (no fallback)
76
110
  * ```ts
77
111
  * const count$ = atom(5);
78
- * const doubled$ = derived(({ get }) => get(count$) * 2);
112
+ * const doubled$ = derived(({ read }) => read(count$) * 2);
79
113
  *
80
- * await doubled$.value; // 10
114
+ * await doubled$.get(); // 10
81
115
  * doubled$.staleValue; // undefined (until first resolve) -> 10
82
116
  * doubled$.state(); // { status: "ready", value: 10 }
83
117
  * ```
@@ -85,7 +119,7 @@ export interface DerivedContext extends SelectContext {}
85
119
  * @example With fallback
86
120
  * ```ts
87
121
  * const posts$ = atom(fetchPosts());
88
- * const count$ = derived(({ get }) => get(posts$).length, { fallback: 0 });
122
+ * const count$ = derived(({ read }) => read(posts$).length, { fallback: 0 });
89
123
  *
90
124
  * count$.staleValue; // 0 (during loading) -> 42 (after resolve)
91
125
  * count$.state(); // { status: "loading", promise } during loading
@@ -105,26 +139,26 @@ export interface DerivedContext extends SelectContext {}
105
139
  *
106
140
  * @example Refresh
107
141
  * ```ts
108
- * const data$ = derived(({ get }) => get(source$));
142
+ * const data$ = derived(({ read }) => read(source$));
109
143
  * data$.refresh(); // Re-run computation
110
144
  * ```
111
145
  */
112
146
 
113
147
  // Overload: Without fallback - staleValue is T | undefined
114
148
  export function derived<T>(
115
- fn: (ctx: DerivedContext) => T,
149
+ fn: ReactiveSelector<T, DerivedContext>,
116
150
  options?: DerivedOptions<T>
117
151
  ): DerivedAtom<T, false>;
118
152
 
119
153
  // Overload: With fallback - staleValue is guaranteed T
120
154
  export function derived<T>(
121
- fn: (ctx: DerivedContext) => T,
155
+ fn: ReactiveSelector<T, DerivedContext>,
122
156
  options: DerivedOptions<T> & { fallback: T }
123
157
  ): DerivedAtom<T, true>;
124
158
 
125
159
  // Implementation
126
160
  export function derived<T>(
127
- fn: (ctx: DerivedContext) => T,
161
+ fn: ReactiveSelector<T, DerivedContext>,
128
162
  options: DerivedOptions<T> & { fallback?: T } = {}
129
163
  ): DerivedAtom<T, boolean> {
130
164
  const changeEmitter = emitter();
@@ -142,6 +176,10 @@ export function derived<T>(
142
176
  let isLoading = false;
143
177
  let version = 0;
144
178
 
179
+ // Store resolve/reject to allow reusing the same promise across recomputations
180
+ let resolvePromise: ((value: T) => void) | null = null;
181
+ let rejectPromise: ((error: unknown) => void) | null = null;
182
+
145
183
  // Track current subscriptions (atom -> unsubscribe function)
146
184
  const subscriptions = new Map<Atom<unknown>, VoidFunction>();
147
185
 
@@ -179,65 +217,95 @@ export function derived<T>(
179
217
 
180
218
  /**
181
219
  * Computes the derived value.
182
- * Creates a new Promise that resolves when the computation completes.
220
+ * Reuses the existing Promise if loading (to prevent orphaned promises
221
+ * that React Suspense might be waiting on).
183
222
  */
184
223
  const compute = (silent = false) => {
185
224
  const computeVersion = ++version;
186
225
  isLoading = true;
187
226
  lastError = undefined; // Clear error when starting new computation
188
227
 
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
- }
228
+ // Create a new promise if:
229
+ // 1. We don't have one yet, OR
230
+ // 2. The previous computation completed (resolved/rejected) and we need a new one
231
+ // This ensures we reuse promises while loading (for Suspense) but create fresh
232
+ // promises for new computations after completion
233
+ if (!resolvePromise) {
234
+ currentPromise = new Promise<T>((resolve, reject) => {
235
+ resolvePromise = resolve;
236
+ rejectPromise = reject;
237
+ });
238
+ }
232
239
 
233
- resolve(newValue);
240
+ // Run select to compute value and track dependencies
241
+ const attemptCompute = () => {
242
+ const result = select((context) => fn(context.use(withReady())));
243
+
244
+ // Update subscriptions based on accessed deps
245
+ updateSubscriptions(result.dependencies);
246
+
247
+ if (result.promise) {
248
+ // Notify subscribers that we're now in loading state
249
+ // This allows downstream derived atoms and useSelector to suspend
250
+ if (!silent) notify();
251
+ // Promise thrown - wait for it and retry
252
+ // Note: For never-resolving promises (from ready()), this .then() will never fire.
253
+ // But when a dependency changes, compute() is called again via subscription,
254
+ // and the new computation will run (with a new version).
255
+ result.promise.then(
256
+ () => {
257
+ // Check if we're still the current computation
258
+ if (version !== computeVersion) return;
259
+ attemptCompute();
260
+ },
261
+ (error) => {
262
+ // Check if we're still the current computation
263
+ if (version !== computeVersion) return;
264
+ isLoading = false;
265
+ lastError = error;
266
+ rejectPromise?.(error);
267
+ // Clear resolve/reject so next computation creates new promise
268
+ resolvePromise = null;
269
+ rejectPromise = null;
270
+ // Always notify when promise rejects - subscribers need to know
271
+ // state changed from loading to error
272
+ notify();
273
+ }
274
+ );
275
+ } else if (result.error !== undefined) {
276
+ // Error thrown
277
+ isLoading = false;
278
+ lastError = result.error;
279
+ rejectPromise?.(result.error);
280
+ // Clear resolve/reject so next computation creates new promise
281
+ resolvePromise = null;
282
+ rejectPromise = null;
283
+ if (!silent) notify();
284
+ } else {
285
+ // Success - update lastResolved and resolve
286
+ const newValue = result.value as T;
287
+ const wasFirstResolve = !lastResolved;
288
+ isLoading = false;
289
+ lastError = undefined;
290
+
291
+ // Only update and notify if value changed
292
+ if (!lastResolved || !eq(newValue, lastResolved.value)) {
293
+ lastResolved = { value: newValue };
294
+ // Always notify on first resolve (loading → ready transition)
295
+ // even if silent, because subscribers need to know state changed
296
+ if (wasFirstResolve || !silent) notify();
234
297
  }
235
- };
236
298
 
237
- attemptCompute();
238
- });
299
+ resolvePromise?.(newValue);
300
+ // Clear resolve/reject so next computation creates new promise
301
+ resolvePromise = null;
302
+ rejectPromise = null;
303
+ }
304
+ };
305
+
306
+ attemptCompute();
239
307
 
240
- return currentPromise;
308
+ return currentPromise!;
241
309
  };
242
310
 
243
311
  /**
@@ -258,10 +326,10 @@ export function derived<T>(
258
326
  meta: options.meta,
259
327
 
260
328
  /**
261
- * The computed value as a Promise.
329
+ * Get the computed value as a Promise.
262
330
  * Always returns Promise<T>, even for sync computations.
263
331
  */
264
- get value(): Promise<T> {
332
+ get(): Promise<T> {
265
333
  init();
266
334
  return currentPromise!;
267
335
  },