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
@@ -1,6 +1,16 @@
1
1
  import { isPromiseLike } from "./isPromiseLike";
2
- import { getAtomState, trackPromise } from "./promiseCache";
3
- import { Atom, AtomValue, SettledResult } from "./types";
2
+ import { getAtomState } from "./getAtomState";
3
+ import { createCombinedPromise } from "./promiseCache";
4
+ import { isAtom } from "./isAtom";
5
+ import {
6
+ Atom,
7
+ AtomValue,
8
+ KeyedResult,
9
+ Pipeable,
10
+ SelectStateResult,
11
+ SettledResult,
12
+ } from "./types";
13
+ import { withUse } from "./withUse";
4
14
 
5
15
  // AggregateError polyfill for environments that don't support it
6
16
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -33,11 +43,19 @@ export interface SelectResult<T> {
33
43
  dependencies: Set<Atom<unknown>>;
34
44
  }
35
45
 
46
+ /**
47
+ * Result type for safe() - error-first tuple.
48
+ * Either [undefined, T] for success or [unknown, undefined] for error.
49
+ */
50
+ export type SafeResult<T> =
51
+ | [error: undefined, result: T]
52
+ | [error: unknown, result: undefined];
53
+
36
54
  /**
37
55
  * Context object passed to selector functions.
38
56
  * Provides utilities for reading atoms and handling async operations.
39
57
  */
40
- export interface SelectContext {
58
+ export interface SelectContext extends Pipeable {
41
59
  /**
42
60
  * Read the current value of an atom.
43
61
  * Tracks the atom as a dependency.
@@ -50,79 +68,192 @@ export interface SelectContext {
50
68
  * @param atom - The atom to read
51
69
  * @returns The atom's current value (Awaited<T>)
52
70
  */
53
- get<T>(atom: Atom<T>): Awaited<T>;
71
+ read<T>(atom: Atom<T>): Awaited<T>;
54
72
 
55
73
  /**
56
74
  * Wait for all atoms to resolve (like Promise.all).
57
- * Variadic form - pass atoms as arguments.
75
+ * Array-based - pass atoms as an array.
58
76
  *
59
77
  * - If all atoms are ready → returns array of values
60
78
  * - If any atom has error → throws that error
61
79
  * - If any atom is loading (no fallback) → throws Promise
62
80
  * - If loading with fallback → uses staleValue
63
81
  *
64
- * @param atoms - Atoms to wait for (variadic)
82
+ * @param atoms - Array of atoms to wait for
65
83
  * @returns Array of resolved values (same order as input)
66
84
  *
67
85
  * @example
68
86
  * ```ts
69
- * const [user, posts] = all(user$, posts$);
87
+ * const [user, posts] = all([user$, posts$]);
70
88
  * ```
71
89
  */
72
- all<A extends Atom<unknown>[]>(
73
- ...atoms: A
74
- ): { [K in keyof A]: AtomValue<A[K]> };
90
+ all<A extends Atom<unknown>[]>(atoms: A): { [K in keyof A]: AtomValue<A[K]> };
75
91
 
76
92
  /**
77
93
  * Return the first settled value (like Promise.race).
78
- * Variadic form - pass atoms as arguments.
94
+ * Object-based - pass atoms as a record with keys.
79
95
  *
80
- * - If any atom is ready → returns first ready value
96
+ * - If any atom is ready → returns `{ key, value }` for first ready
81
97
  * - If any atom has error → throws first error
82
98
  * - If all atoms are loading → throws first Promise
83
99
  *
100
+ * The `key` in the result identifies which atom won the race.
101
+ *
84
102
  * Note: race() does NOT use fallback - it's meant for first "real" settled value.
85
103
  *
86
- * @param atoms - Atoms to race (variadic)
87
- * @returns First settled value
104
+ * @param atoms - Record of atoms to race
105
+ * @returns KeyedResult with winning key and value
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * const result = race({ cache: cache$, api: api$ });
110
+ * console.log(result.key); // "cache" or "api"
111
+ * console.log(result.value); // The winning value
112
+ * ```
88
113
  */
89
- race<A extends Atom<unknown>[]>(...atoms: A): AtomValue<A[number]>;
114
+ race<T extends Record<string, Atom<unknown>>>(
115
+ atoms: T
116
+ ): KeyedResult<keyof T & string, AtomValue<T[keyof T]>>;
90
117
 
91
118
  /**
92
119
  * Return the first ready value (like Promise.any).
93
- * Variadic form - pass atoms as arguments.
120
+ * Object-based - pass atoms as a record with keys.
94
121
  *
95
- * - If any atom is ready → returns first ready value
122
+ * - If any atom is ready → returns `{ key, value }` for first ready
96
123
  * - If all atoms have errors → throws AggregateError
97
124
  * - If any loading (not all errored) → throws Promise
98
125
  *
126
+ * The `key` in the result identifies which atom resolved first.
127
+ *
99
128
  * Note: any() does NOT use fallback - it waits for a real ready value.
100
129
  *
101
- * @param atoms - Atoms to check (variadic)
102
- * @returns First ready value
130
+ * @param atoms - Record of atoms to check
131
+ * @returns KeyedResult with winning key and value
132
+ *
133
+ * @example
134
+ * ```ts
135
+ * const result = any({ primary: primaryApi$, fallback: fallbackApi$ });
136
+ * console.log(result.key); // "primary" or "fallback"
137
+ * console.log(result.value); // The winning value
138
+ * ```
103
139
  */
104
- any<A extends Atom<unknown>[]>(...atoms: A): AtomValue<A[number]>;
140
+ any<T extends Record<string, Atom<unknown>>>(
141
+ atoms: T
142
+ ): KeyedResult<keyof T & string, AtomValue<T[keyof T]>>;
105
143
 
106
144
  /**
107
145
  * Get all atom statuses when all are settled (like Promise.allSettled).
108
- * Variadic form - pass atoms as arguments.
146
+ * Array-based - pass atoms as an array.
109
147
  *
110
148
  * - If all atoms are settled → returns array of statuses
111
149
  * - If any atom is loading (no fallback) → throws Promise
112
150
  * - If loading with fallback → { status: "ready", value: staleValue }
113
151
  *
114
- * @param atoms - Atoms to check (variadic)
152
+ * @param atoms - Array of atoms to check
115
153
  * @returns Array of settled results
154
+ *
155
+ * @example
156
+ * ```ts
157
+ * const [userResult, postsResult] = settled([user$, posts$]);
158
+ * ```
116
159
  */
117
160
  settled<A extends Atom<unknown>[]>(
118
- ...atoms: A
161
+ atoms: A
119
162
  ): { [K in keyof A]: SettledResult<AtomValue<A[K]>> };
163
+
164
+ /**
165
+ * Safely execute a function, catching errors but preserving Suspense.
166
+ *
167
+ * - If function succeeds → returns [undefined, result]
168
+ * - If function throws Error → returns [error, undefined]
169
+ * - If function throws Promise → re-throws (preserves Suspense)
170
+ *
171
+ * Use this when you need error handling inside selectors without
172
+ * accidentally catching Suspense promises.
173
+ *
174
+ * @param fn - Function to execute safely
175
+ * @returns Error-first tuple: [error, result]
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * const data$ = derived(({ get, safe }) => {
180
+ * const [err, data] = safe(() => {
181
+ * const raw = get(raw$);
182
+ * return JSON.parse(raw); // Can throw SyntaxError
183
+ * });
184
+ *
185
+ * if (err) {
186
+ * return { error: err.message };
187
+ * }
188
+ * return { data };
189
+ * });
190
+ * ```
191
+ */
192
+ safe<T>(fn: () => T): SafeResult<T>;
193
+
194
+ /**
195
+ * Get the async state of an atom or selector without throwing.
196
+ *
197
+ * Unlike `read()` which throws promises/errors (Suspense pattern),
198
+ * `state()` always returns a `SelectStateResult<T>` object that you can
199
+ * inspect and handle inline.
200
+ *
201
+ * All properties (`status`, `value`, `error`) are always present,
202
+ * enabling easy destructuring:
203
+ * ```ts
204
+ * const { status, value, error } = state(atom$);
205
+ * ```
206
+ *
207
+ * @param atom - The atom to get state from
208
+ * @returns SelectStateResult with status, value, error (no promise - for equality)
209
+ *
210
+ * @example
211
+ * ```ts
212
+ * // Get state of single atom
213
+ * const dashboard$ = derived(({ state }) => {
214
+ * const userState = state(user$);
215
+ * const postsState = state(posts$);
216
+ *
217
+ * return {
218
+ * user: userState.value, // undefined if not ready
219
+ * isLoading: userState.status === 'loading' || postsState.status === 'loading',
220
+ * };
221
+ * });
222
+ * ```
223
+ */
224
+ state<T>(atom: Atom<T>): SelectStateResult<Awaited<T>>;
225
+
226
+ /**
227
+ * Get the async state of a selector function without throwing.
228
+ *
229
+ * Wraps the selector in try/catch and returns the result as a
230
+ * `SelectStateResult<T>` object. Useful for getting state of combined
231
+ * operations like `all()`, `race()`, etc.
232
+ *
233
+ * @param selector - Function that may throw promises or errors
234
+ * @returns SelectStateResult with status, value, error (no promise - for equality)
235
+ *
236
+ * @example
237
+ * ```ts
238
+ * // Get state of combined operation
239
+ * const allData$ = derived(({ state, all }) => {
240
+ * const result = state(() => all(a$, b$, c$));
241
+ *
242
+ * if (result.status === 'loading') return { loading: true };
243
+ * if (result.status === 'error') return { error: result.error };
244
+ * return { data: result.value };
245
+ * });
246
+ * ```
247
+ */
248
+ state<T>(selector: () => T): SelectStateResult<T>;
120
249
  }
121
250
 
122
251
  /**
123
252
  * Selector function type for context-based API.
124
253
  */
125
- export type ContextSelectorFn<T> = (context: SelectContext) => T;
254
+ export type ReactiveSelector<T, C extends SelectContext = SelectContext> = (
255
+ context: C
256
+ ) => T;
126
257
 
127
258
  /**
128
259
  * Custom error for when all atoms in `any()` are rejected.
@@ -145,7 +276,7 @@ export class AllAtomsRejectedError extends Error {
145
276
  * Selects/computes a value from atom(s) with dependency tracking.
146
277
  *
147
278
  * This is the core computation logic used by `derived()`. It:
148
- * 1. Creates a context with `get`, `all`, `any`, `race`, `settled` utilities
279
+ * 1. Creates a context with `read`, `all`, `any`, `race`, `settled`, `safe` utilities
149
280
  * 2. Tracks which atoms are accessed during computation
150
281
  * 3. Returns a result with value/error/promise and dependencies
151
282
  *
@@ -158,40 +289,128 @@ export class AllAtomsRejectedError extends Error {
158
289
  * If your selector returns a Promise, it will throw an error. This is because:
159
290
  * - `select()` is designed for synchronous derivation from atoms
160
291
  * - Async atoms should be created using `atom(Promise)` directly
161
- * - Use `get()` to read async atoms - it handles Suspense-style loading
292
+ * - Use `read()` to read async atoms - it handles Suspense-style loading
162
293
  *
163
294
  * ```ts
164
295
  * // ❌ WRONG - Don't return a Promise from selector
165
296
  * select(({ get }) => fetch('/api/data'));
166
297
  *
167
- * // ✅ CORRECT - Create async atom and read with get()
298
+ * // ✅ CORRECT - Create async atom and read with read()
168
299
  * const data$ = atom(fetch('/api/data').then(r => r.json()));
169
- * select(({ get }) => get(data$)); // Suspends until resolved
300
+ * select(({ read }) => read(data$)); // Suspends until resolved
301
+ * ```
302
+ *
303
+ * ## IMPORTANT: Do NOT Use try/catch - Use safe() Instead
304
+ *
305
+ * **Never wrap `read()` calls in try/catch blocks.** The `read()` function throws
306
+ * Promises when atoms are loading (Suspense pattern). A try/catch will catch
307
+ * these Promises and break the Suspense mechanism.
308
+ *
309
+ * ```ts
310
+ * // ❌ WRONG - Catches Suspense Promise, breaks loading state
311
+ * select(({ read }) => {
312
+ * try {
313
+ * return read(asyncAtom$);
314
+ * } catch (e) {
315
+ * return 'fallback'; // This catches BOTH errors AND loading promises!
316
+ * }
317
+ * });
318
+ *
319
+ * // ✅ CORRECT - Use safe() to catch errors but preserve Suspense
320
+ * select(({ read, safe }) => {
321
+ * const [err, data] = safe(() => {
322
+ * const raw = read(asyncAtom$); // Can throw Promise (Suspense)
323
+ * return JSON.parse(raw); // Can throw Error
324
+ * });
325
+ *
326
+ * if (err) return { error: err.message };
327
+ * return { data };
328
+ * });
329
+ * ```
330
+ *
331
+ * The `safe()` utility:
332
+ * - **Catches errors** and returns `[error, undefined]`
333
+ * - **Re-throws Promises** to preserve Suspense behavior
334
+ * - Returns `[undefined, result]` on success
335
+ *
336
+ * ## IMPORTANT: SelectContext Methods Are Synchronous Only
337
+ *
338
+ * **All context methods (`read`, `all`, `race`, `any`, `settled`, `safe`) must be
339
+ * called synchronously during selector execution.** They cannot be used in async
340
+ * callbacks like `setTimeout`, `Promise.then`, or event handlers.
341
+ *
342
+ * ```ts
343
+ * // ❌ WRONG - Calling read() in async callback
344
+ * select(({ read }) => {
345
+ * setTimeout(() => {
346
+ * read(atom$); // Error: called outside selection context
347
+ * }, 100);
348
+ * return 'value';
349
+ * });
350
+ *
351
+ * // ❌ WRONG - Storing read() for later use
352
+ * let savedRead;
353
+ * select(({ read }) => {
354
+ * savedRead = read; // Don't do this!
355
+ * return read(atom$);
356
+ * });
357
+ * savedRead(atom$); // Error: called outside selection context
358
+ *
359
+ * // ✅ CORRECT - For async access, use atom.get() directly
360
+ * effect(({ read }) => {
361
+ * const config = read(config$);
362
+ * setTimeout(async () => {
363
+ * // Use atom.get() for async access
364
+ * const data = await asyncAtom$.get();
365
+ * console.log(data);
366
+ * }, 100);
367
+ * });
170
368
  * ```
171
369
  *
172
370
  * @template T - The type of the computed value
173
371
  * @param fn - Context-based selector function (must return sync value)
174
372
  * @returns SelectResult with value, error, promise, and dependencies
175
373
  * @throws Error if selector returns a Promise or PromiseLike
374
+ * @throws Error if context methods are called outside selection context
176
375
  *
177
376
  * @example
178
377
  * ```ts
179
- * select(({ get, all }) => {
180
- * const user = get(user$);
181
- * const [posts, comments] = all(posts$, comments$);
378
+ * select(({ read, all }) => {
379
+ * const user = read(user$);
380
+ * const [posts, comments] = all([posts$, comments$]);
182
381
  * return { user, posts, comments };
183
382
  * });
184
383
  * ```
185
384
  */
186
- export function select<T>(fn: ContextSelectorFn<T>): SelectResult<T> {
385
+ export function select<T>(fn: ReactiveSelector<T>): SelectResult<T> {
187
386
  // Track accessed dependencies during computation
188
387
  const dependencies = new Set<Atom<unknown>>();
189
388
 
389
+ // Flag to detect calls outside selection context (e.g., in async callbacks)
390
+ let isExecuting = true;
391
+
392
+ /**
393
+ * Throws an error if called outside the synchronous selection context.
394
+ * This catches common mistakes like using get() in async callbacks.
395
+ */
396
+ const assertExecuting = (methodName: string) => {
397
+ if (!isExecuting) {
398
+ throw new Error(
399
+ `${methodName}() was called outside of the selection context. ` +
400
+ "This usually happens when calling context methods in async callbacks (setTimeout, Promise.then, etc.). " +
401
+ "All atom reads must happen synchronously during selector execution. " +
402
+ "For async access, use atom.get() directly (e.g., myMutableAtom$.get() or await myDerivedAtom$.get())."
403
+ );
404
+ }
405
+ };
406
+
190
407
  /**
191
408
  * Read atom value using getAtomState().
192
- * Implements the v2 get() behavior from spec.
409
+ * Implements Suspense-like behavior.
193
410
  */
194
- const get = <V>(atom: Atom<V>): Awaited<V> => {
411
+ const read = <V>(atom: Atom<V>): Awaited<V> => {
412
+ assertExecuting("read");
413
+
195
414
  // Track this atom as accessed dependency
196
415
  dependencies.add(atom as Atom<unknown>);
197
416
 
@@ -209,12 +428,15 @@ export function select<T>(fn: ContextSelectorFn<T>): SelectResult<T> {
209
428
 
210
429
  /**
211
430
  * all() - like Promise.all
431
+ * Array-based: waits for ALL loading atoms in parallel
212
432
  */
213
433
  const all = <A extends Atom<unknown>[]>(
214
- ...atoms: A
434
+ atoms: A
215
435
  ): { [K in keyof A]: AtomValue<A[K]> } => {
436
+ assertExecuting("all");
437
+
216
438
  const results: unknown[] = [];
217
- let loadingPromise: Promise<unknown> | null = null;
439
+ const loadingPromises: Promise<unknown>[] = [];
218
440
 
219
441
  for (const atom of atoms) {
220
442
  dependencies.add(atom);
@@ -230,17 +452,15 @@ export function select<T>(fn: ContextSelectorFn<T>): SelectResult<T> {
230
452
  throw state.error;
231
453
 
232
454
  case "loading":
233
- // First loading without fallback will throw
234
- if (!loadingPromise) {
235
- loadingPromise = state.promise;
236
- }
455
+ // Collect ALL loading promises for parallel waiting
456
+ loadingPromises.push(state.promise!);
237
457
  break;
238
458
  }
239
459
  }
240
460
 
241
- // If any loading without fallback → throw Promise
242
- if (loadingPromise) {
243
- throw loadingPromise;
461
+ // If any loading → throw combined Promise.all for parallel waiting
462
+ if (loadingPromises.length > 0) {
463
+ throw createCombinedPromise("all", loadingPromises);
244
464
  }
245
465
 
246
466
  return results as { [K in keyof A]: AtomValue<A[K]> };
@@ -248,36 +468,41 @@ export function select<T>(fn: ContextSelectorFn<T>): SelectResult<T> {
248
468
 
249
469
  /**
250
470
  * race() - like Promise.race
471
+ * Object-based: races all atoms, returns { key, value } for winner
251
472
  */
252
- const race = <A extends Atom<unknown>[]>(
253
- ...atoms: A
254
- ): AtomValue<A[number]> => {
255
- let firstLoadingPromise: Promise<unknown> | null = null;
473
+ const race = <T extends Record<string, Atom<unknown>>>(
474
+ atoms: T
475
+ ): KeyedResult<keyof T & string, AtomValue<T[keyof T]>> => {
476
+ assertExecuting("race");
256
477
 
257
- for (const atom of atoms) {
478
+ const loadingPromises: Promise<unknown>[] = [];
479
+ const entries = Object.entries(atoms);
480
+
481
+ for (const [key, atom] of entries) {
258
482
  dependencies.add(atom);
259
483
 
260
484
  // For race(), we need raw state without fallback handling
261
- const state = getAtomStateRaw(atom);
485
+ const state = getAtomState(atom);
262
486
 
263
487
  switch (state.status) {
264
488
  case "ready":
265
- return state.value as AtomValue<A[number]>;
489
+ return {
490
+ key: key as keyof T & string,
491
+ value: state.value as AtomValue<T[keyof T]>,
492
+ };
266
493
 
267
494
  case "error":
268
495
  throw state.error;
269
496
 
270
497
  case "loading":
271
- if (!firstLoadingPromise) {
272
- firstLoadingPromise = state.promise;
273
- }
498
+ loadingPromises.push(state.promise!);
274
499
  break;
275
500
  }
276
501
  }
277
502
 
278
- // All loading → throw first Promise
279
- if (firstLoadingPromise) {
280
- throw firstLoadingPromise;
503
+ // All loading → race them (first to settle wins)
504
+ if (loadingPromises.length > 0) {
505
+ throw createCombinedPromise("race", loadingPromises);
281
506
  }
282
507
 
283
508
  throw new Error("race() called with no atoms");
@@ -285,38 +510,43 @@ export function select<T>(fn: ContextSelectorFn<T>): SelectResult<T> {
285
510
 
286
511
  /**
287
512
  * any() - like Promise.any
513
+ * Object-based: returns { key, value } for first ready atom
288
514
  */
289
- const any = <A extends Atom<unknown>[]>(
290
- ...atoms: A
291
- ): AtomValue<A[number]> => {
515
+ const any = <T extends Record<string, Atom<unknown>>>(
516
+ atoms: T
517
+ ): KeyedResult<keyof T & string, AtomValue<T[keyof T]>> => {
518
+ assertExecuting("any");
519
+
292
520
  const errors: unknown[] = [];
293
- let firstLoadingPromise: Promise<unknown> | null = null;
521
+ const loadingPromises: Promise<unknown>[] = [];
522
+ const entries = Object.entries(atoms);
294
523
 
295
- for (const atom of atoms) {
524
+ for (const [key, atom] of entries) {
296
525
  dependencies.add(atom);
297
526
 
298
527
  // For any(), we need raw state without fallback handling
299
- const state = getAtomStateRaw(atom);
528
+ const state = getAtomState(atom);
300
529
 
301
530
  switch (state.status) {
302
531
  case "ready":
303
- return state.value as AtomValue<A[number]>;
532
+ return {
533
+ key: key as keyof T & string,
534
+ value: state.value as AtomValue<T[keyof T]>,
535
+ };
304
536
 
305
537
  case "error":
306
538
  errors.push(state.error);
307
539
  break;
308
540
 
309
541
  case "loading":
310
- if (!firstLoadingPromise) {
311
- firstLoadingPromise = state.promise;
312
- }
542
+ loadingPromises.push(state.promise!);
313
543
  break;
314
544
  }
315
545
  }
316
546
 
317
- // If any loading → throw Promise (might still fulfill)
318
- if (firstLoadingPromise) {
319
- throw firstLoadingPromise;
547
+ // If any loading → race them all (first to resolve wins)
548
+ if (loadingPromises.length > 0) {
549
+ throw createCombinedPromise("race", loadingPromises);
320
550
  }
321
551
 
322
552
  // All errored → throw AggregateError
@@ -325,12 +555,15 @@ export function select<T>(fn: ContextSelectorFn<T>): SelectResult<T> {
325
555
 
326
556
  /**
327
557
  * settled() - like Promise.allSettled
558
+ * Array-based: waits for ALL atoms in parallel
328
559
  */
329
560
  const settled = <A extends Atom<unknown>[]>(
330
- ...atoms: A
561
+ atoms: A
331
562
  ): { [K in keyof A]: SettledResult<AtomValue<A[K]>> } => {
563
+ assertExecuting("settled");
564
+
332
565
  const results: SettledResult<unknown>[] = [];
333
- let pendingPromise: Promise<unknown> | null = null;
566
+ const loadingPromises: Promise<unknown>[] = [];
334
567
 
335
568
  for (const atom of atoms) {
336
569
  dependencies.add(atom);
@@ -346,24 +579,114 @@ export function select<T>(fn: ContextSelectorFn<T>): SelectResult<T> {
346
579
  break;
347
580
 
348
581
  case "loading":
349
- // Loading without fallback will throw
350
- if (!pendingPromise) {
351
- pendingPromise = state.promise;
352
- }
582
+ // Collect ALL loading promises for parallel waiting
583
+ loadingPromises.push(state.promise!);
353
584
  break;
354
585
  }
355
586
  }
356
587
 
357
- // If any loading without fallback → throw Promise
358
- if (pendingPromise) {
359
- throw pendingPromise;
588
+ // If any loading → throw combined Promise.allSettled for parallel waiting
589
+ // (allSettled never rejects, so we wait for ALL to settle regardless of success/failure)
590
+ if (loadingPromises.length > 0) {
591
+ throw createCombinedPromise("allSettled", loadingPromises);
360
592
  }
361
593
 
362
594
  return results as { [K in keyof A]: SettledResult<AtomValue<A[K]>> };
363
595
  };
364
596
 
597
+ /**
598
+ * safe() - Execute function with error handling, preserving Suspense
599
+ */
600
+ const safe = <T>(fn: () => T): SafeResult<T> => {
601
+ assertExecuting("safe");
602
+
603
+ try {
604
+ const result = fn();
605
+ return [undefined, result];
606
+ } catch (e) {
607
+ // Re-throw Promises to preserve Suspense behavior
608
+ if (isPromiseLike(e)) {
609
+ throw e;
610
+ }
611
+ // Return errors as tuple instead of throwing
612
+ return [e, undefined];
613
+ }
614
+ };
615
+
616
+ /**
617
+ * state() - Get async state without throwing
618
+ * Overloaded: accepts atom or selector function
619
+ * Returns SelectStateResult with placeholder props for equality-friendly comparisons
620
+ */
621
+ function state<T>(atom: Atom<T>): SelectStateResult<Awaited<T>>;
622
+ function state<T>(selector: () => T): SelectStateResult<T>;
623
+ function state<T>(
624
+ atomOrSelector: Atom<T> | (() => T)
625
+ ): SelectStateResult<Awaited<T>> | SelectStateResult<T> {
626
+ assertExecuting("state");
627
+
628
+ // Atom shorthand - get state directly and convert to SelectStateResult
629
+ if (isAtom(atomOrSelector)) {
630
+ dependencies.add(atomOrSelector as Atom<unknown>);
631
+ const atomState = getAtomState(atomOrSelector);
632
+
633
+ // Convert AtomState to SelectStateResult (remove promise, add placeholders)
634
+ switch (atomState.status) {
635
+ case "ready":
636
+ return {
637
+ status: "ready",
638
+ value: atomState.value,
639
+ error: undefined,
640
+ } as SelectStateResult<Awaited<T>>;
641
+ case "error":
642
+ return {
643
+ status: "error",
644
+ value: undefined,
645
+ error: atomState.error,
646
+ } as SelectStateResult<Awaited<T>>;
647
+ case "loading":
648
+ return {
649
+ status: "loading",
650
+ value: undefined,
651
+ error: undefined,
652
+ } as SelectStateResult<Awaited<T>>;
653
+ }
654
+ }
655
+
656
+ // Selector function - wrap in try/catch
657
+ try {
658
+ const value = atomOrSelector();
659
+ return {
660
+ status: "ready",
661
+ value,
662
+ error: undefined,
663
+ } as SelectStateResult<T>;
664
+ } catch (e) {
665
+ if (isPromiseLike(e)) {
666
+ return {
667
+ status: "loading",
668
+ value: undefined,
669
+ error: undefined,
670
+ } as SelectStateResult<T>;
671
+ }
672
+ return {
673
+ status: "error",
674
+ value: undefined,
675
+ error: e,
676
+ } as SelectStateResult<T>;
677
+ }
678
+ }
679
+
365
680
  // Create the context
366
- const context: SelectContext = { get, all, any, race, settled };
681
+ const context: SelectContext = withUse({
682
+ read,
683
+ all,
684
+ any,
685
+ race,
686
+ settled,
687
+ safe,
688
+ state,
689
+ });
367
690
 
368
691
  // Execute the selector function
369
692
  try {
@@ -399,56 +722,8 @@ export function select<T>(fn: ContextSelectorFn<T>): SelectResult<T> {
399
722
  dependencies,
400
723
  };
401
724
  }
402
- }
403
- }
404
-
405
- // ============================================================================
406
- // Internal helpers
407
- // ============================================================================
408
-
409
- // Note: trackPromise is already imported at the top
410
-
411
- /**
412
- * Gets raw atom state WITHOUT fallback handling.
413
- * Used by race() and any() which need the actual loading state.
414
- */
415
- function getAtomStateRaw<T>(
416
- atom: Atom<T>
417
- ):
418
- | { status: "ready"; value: Awaited<T> }
419
- | { status: "error"; error: unknown }
420
- | { status: "loading"; promise: Promise<Awaited<T>> } {
421
- const value = atom.value;
422
-
423
- // 1. Sync value - ready
424
- if (!isPromiseLike(value)) {
425
- return {
426
- status: "ready",
427
- value: value as Awaited<T>,
428
- };
429
- }
430
-
431
- // 2. Promise value - check state via promiseCache
432
- const state = trackPromise(value);
433
-
434
- switch (state.status) {
435
- case "fulfilled":
436
- return {
437
- status: "ready",
438
- value: state.value as Awaited<T>,
439
- };
440
-
441
- case "rejected":
442
- return {
443
- status: "error",
444
- error: state.error,
445
- };
446
-
447
- case "pending":
448
- // Raw - don't use fallback
449
- return {
450
- status: "loading",
451
- promise: state.promise as Promise<Awaited<T>>,
452
- };
725
+ } finally {
726
+ // Mark execution as complete to catch async misuse
727
+ isExecuting = false;
453
728
  }
454
729
  }