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,15 +1,16 @@
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";
6
- import { EffectOptions } from "./types";
4
+ import { EffectInfo, onCreateHook } from "./onCreateHook";
5
+ import { ReactiveSelector, SelectContext } from "./select";
6
+ import { EffectMeta, EffectOptions } from "./types";
7
+ import { WithReadySelectContext } from "./withReady";
7
8
 
8
9
  /**
9
10
  * Context object passed to effect functions.
10
- * Extends `SelectContext` with cleanup and error handling utilities.
11
+ * Extends `SelectContext` with cleanup utilities.
11
12
  */
12
- export interface EffectContext extends SelectContext {
13
+ export interface EffectContext extends SelectContext, WithReadySelectContext {
13
14
  /**
14
15
  * Register a cleanup function that runs before the next execution or on dispose.
15
16
  * Multiple cleanup functions can be registered; they run in FIFO order.
@@ -18,36 +19,19 @@ export interface EffectContext extends SelectContext {
18
19
  *
19
20
  * @example
20
21
  * ```ts
21
- * effect(({ get, onCleanup }) => {
22
+ * effect(({ read, onCleanup }) => {
22
23
  * const id = setInterval(() => console.log('tick'), 1000);
23
24
  * onCleanup(() => clearInterval(id));
24
25
  * });
25
26
  * ```
26
27
  */
27
28
  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
29
  }
45
30
 
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;
31
+ export interface Effect {
32
+ dispose: VoidFunction;
33
+ meta?: EffectMeta;
34
+ }
51
35
 
52
36
  /**
53
37
  * Creates a side-effect that runs when accessed atom(s) change.
@@ -55,7 +39,7 @@ export type EffectFn = (context: EffectContext) => void;
55
39
  * Effects are similar to derived atoms but for side-effects rather than computed values.
56
40
  * They inherit derived's behavior:
57
41
  * - **Suspense-like async**: Waits for async atoms to resolve before running
58
- * - **Conditional dependencies**: Only tracks atoms actually accessed via `get()`
42
+ * - **Conditional dependencies**: Only tracks atoms actually accessed via `read()`
59
43
  * - **Automatic cleanup**: Previous cleanup runs before next execution
60
44
  * - **Batched updates**: Atom updates within the effect are batched
61
45
  *
@@ -65,23 +49,23 @@ export type EffectFn = (context: EffectContext) => void;
65
49
  *
66
50
  * ```ts
67
51
  * // ❌ WRONG - Don't use async function
68
- * effect(async ({ get }) => {
52
+ * effect(async ({ read }) => {
69
53
  * const data = await fetch('/api');
70
54
  * console.log(data);
71
55
  * });
72
56
  *
73
- * // ✅ CORRECT - Create async atom and read with get()
57
+ * // ✅ CORRECT - Create async atom and read with read()
74
58
  * const data$ = atom(fetch('/api').then(r => r.json()));
75
- * effect(({ get }) => {
76
- * console.log(get(data$)); // Suspends until resolved
59
+ * effect(({ read }) => {
60
+ * console.log(read(data$)); // Suspends until resolved
77
61
  * });
78
62
  * ```
79
63
  *
80
64
  * ## Basic Usage
81
65
  *
82
66
  * ```ts
83
- * const dispose = effect(({ get }) => {
84
- * localStorage.setItem('count', String(get(countAtom)));
67
+ * const dispose = effect(({ read }) => {
68
+ * localStorage.setItem('count', String(read(countAtom)));
85
69
  * });
86
70
  * ```
87
71
  *
@@ -90,95 +74,115 @@ export type EffectFn = (context: EffectContext) => void;
90
74
  * Use `onCleanup` to register cleanup functions that run before the next execution or on dispose:
91
75
  *
92
76
  * ```ts
93
- * const dispose = effect(({ get, onCleanup }) => {
94
- * const interval = get(intervalAtom);
77
+ * const dispose = effect(({ read, onCleanup }) => {
78
+ * const interval = read(intervalAtom);
95
79
  * const id = setInterval(() => console.log('tick'), interval);
96
80
  * onCleanup(() => clearInterval(id));
97
81
  * });
98
82
  * ```
99
83
  *
100
- * ## Error Handling
84
+ * ## IMPORTANT: Do NOT Use try/catch - Use safe() Instead
101
85
  *
102
- * Use `onError` callback to handle errors within the effect, or `options.onError` for unhandled errors:
86
+ * **Never wrap `read()` calls in try/catch blocks.** The `read()` function throws
87
+ * Promises when atoms are loading (Suspense pattern). A try/catch will catch
88
+ * these Promises and break the Suspense mechanism.
103
89
  *
104
90
  * ```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);
91
+ * // ❌ WRONG - Catches Suspense Promise, breaks loading state
92
+ * effect(({ read }) => {
93
+ * try {
94
+ * const data = read(asyncAtom$);
95
+ * riskyOperation(data);
96
+ * } catch (e) {
97
+ * console.error(e); // Catches BOTH errors AND loading promises!
98
+ * }
110
99
  * });
111
100
  *
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
- * );
101
+ * // ✅ CORRECT - Use safe() to catch errors but preserve Suspense
102
+ * effect(({ read, safe }) => {
103
+ * const [err, data] = safe(() => {
104
+ * const raw = read(asyncAtom$); // Can throw Promise (Suspense)
105
+ * return riskyOperation(raw); // Can throw Error
106
+ * });
107
+ *
108
+ * if (err) {
109
+ * console.error('Operation failed:', err);
110
+ * return;
111
+ * }
112
+ * // Use data safely
113
+ * });
120
114
  * ```
121
115
  *
122
- * @param fn - Effect callback receiving context with `{ get, all, any, race, settled, onCleanup, onError }`.
116
+ * The `safe()` utility:
117
+ * - **Catches errors** and returns `[error, undefined]`
118
+ * - **Re-throws Promises** to preserve Suspense behavior
119
+ * - Returns `[undefined, result]` on success
120
+ *
121
+ * @param fn - Effect callback receiving context with `{ read, all, any, race, settled, safe, onCleanup }`.
123
122
  * Must be synchronous (not async).
124
- * @param options - Optional configuration (key, onError for unhandled errors)
123
+ * @param options - Optional configuration (key)
125
124
  * @returns Dispose function to stop the effect and run final cleanup
126
125
  * @throws Error if effect function returns a Promise
127
126
  */
128
- export function effect(fn: EffectFn, options?: EffectOptions): VoidFunction {
127
+ export function effect(
128
+ fn: ReactiveSelector<void, EffectContext>,
129
+ options?: EffectOptions
130
+ ): Effect {
129
131
  let disposed = false;
130
132
  const cleanupEmitter = emitter();
131
- const errorEmitter = emitter<unknown>();
133
+
134
+ // Create the Effect object early so we can build EffectInfo
135
+ const e: Effect = {
136
+ meta: options?.meta,
137
+ dispose: () => {
138
+ // Guard against multiple dispose calls
139
+ if (disposed) return;
140
+
141
+ // Mark as disposed
142
+ disposed = true;
143
+ // Run final cleanup
144
+ cleanupEmitter.emitAndClear();
145
+ },
146
+ };
147
+
148
+ // Create EffectInfo to pass to derived for error attribution
149
+ const effectInfo: EffectInfo = {
150
+ type: "effect",
151
+ key: options?.meta?.key,
152
+ meta: options?.meta,
153
+ instance: e,
154
+ };
132
155
 
133
156
  // Create a derived atom that runs the effect on each recomputation.
134
- const derivedAtom = derived((context) => {
135
- // Run previous cleanup before next execution
136
- errorEmitter.clear();
137
- cleanupEmitter.emitAndClear();
157
+ // Pass _errorSource so errors are attributed to the effect, not the internal derived
158
+ const derivedAtom = derived(
159
+ (context) => {
160
+ // Run previous cleanup before next execution
161
+ cleanupEmitter.emitAndClear();
138
162
 
139
- // Skip effect execution if disposed
140
- if (disposed) return;
163
+ // Skip effect execution if disposed
164
+ if (disposed) return;
141
165
 
142
- try {
143
166
  // 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
- }
167
+ // Cast to EffectContext since we're adding onCleanup to the DerivedContext
168
+ const effectContext = {
169
+ ...context,
170
+ onCleanup: cleanupEmitter.on,
171
+ } as unknown as EffectContext;
172
+ batch(() => fn(effectContext));
173
+ },
174
+ {
175
+ onError: options?.onError,
176
+ _errorSource: effectInfo,
162
177
  }
163
- });
178
+ );
164
179
 
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
172
- });
180
+ // Access .get() to trigger initial computation (derived is lazy)
181
+ // Errors are handled via onError callback or safe() in the effect function
182
+ derivedAtom.get();
173
183
 
174
- return () => {
175
- // Guard against multiple dispose calls
176
- if (disposed) return;
184
+ // Notify devtools/plugins of effect creation
185
+ onCreateHook.current?.(effectInfo);
177
186
 
178
- // Mark as disposed
179
- disposed = true;
180
- errorEmitter.clear();
181
- // Run final cleanup
182
- cleanupEmitter.emitAndClear();
183
- };
187
+ return e;
184
188
  }
@@ -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
+ }
@@ -176,20 +176,20 @@ describe("hook", () => {
176
176
  const hookA = hook<string | undefined>();
177
177
  const hookB = hook<string | undefined>();
178
178
 
179
- // Create custom setups that track release order
179
+ // Create custom setups that track release order using override/reset
180
180
  const setupA = () => {
181
- hookA.current = "A";
181
+ hookA.override(() => "A");
182
182
  return () => {
183
183
  order.push("release A");
184
- hookA.current = undefined;
184
+ hookA.reset();
185
185
  };
186
186
  };
187
187
 
188
188
  const setupB = () => {
189
- hookB.current = "B";
189
+ hookB.override(() => "B");
190
190
  return () => {
191
191
  order.push("release B");
192
- hookB.current = undefined;
192
+ hookB.reset();
193
193
  };
194
194
  };
195
195
 
package/src/core/hook.ts CHANGED
@@ -56,7 +56,7 @@ export interface Hook<T> {
56
56
  /**
57
57
  * Current value of the hook. Direct property access for fast reads.
58
58
  */
59
- current: T;
59
+ readonly current: T;
60
60
 
61
61
  /**
62
62
  * Override the current value using a reducer.
@@ -1,3 +1,4 @@
1
+ import { Effect } from "./effect";
1
2
  import { hook } from "./hook";
2
3
  import {
3
4
  MutableAtomMeta,
@@ -5,12 +6,13 @@ import {
5
6
  MutableAtom,
6
7
  DerivedAtom,
7
8
  ModuleMeta,
9
+ EffectMeta,
8
10
  } from "./types";
9
11
 
10
12
  /**
11
13
  * Information provided when a mutable atom is created.
12
14
  */
13
- export interface MutableAtomCreateInfo {
15
+ export interface MutableInfo {
14
16
  /** Discriminator for mutable atoms */
15
17
  type: "mutable";
16
18
  /** Optional key from atom options (for debugging/devtools) */
@@ -18,13 +20,13 @@ export interface MutableAtomCreateInfo {
18
20
  /** Optional metadata from atom options */
19
21
  meta: MutableAtomMeta | undefined;
20
22
  /** The created mutable atom instance */
21
- atom: MutableAtom<unknown>;
23
+ instance: MutableAtom<unknown>;
22
24
  }
23
25
 
24
26
  /**
25
27
  * Information provided when a derived atom is created.
26
28
  */
27
- export interface DerivedAtomCreateInfo {
29
+ export interface DerivedInfo {
28
30
  /** Discriminator for derived atoms */
29
31
  type: "derived";
30
32
  /** Optional key from derived options (for debugging/devtools) */
@@ -32,18 +34,32 @@ export interface DerivedAtomCreateInfo {
32
34
  /** Optional metadata from derived options */
33
35
  meta: DerivedAtomMeta | undefined;
34
36
  /** The created derived atom instance */
35
- atom: DerivedAtom<unknown, boolean>;
37
+ instance: DerivedAtom<unknown, boolean>;
36
38
  }
37
39
 
38
40
  /**
39
- * Union type for atom creation info (mutable or derived).
41
+ * Information provided when an effect is created.
40
42
  */
41
- export type AtomCreateInfo = MutableAtomCreateInfo | DerivedAtomCreateInfo;
43
+ export interface EffectInfo {
44
+ /** Discriminator for effects */
45
+ type: "effect";
46
+ /** Optional key from effect options (for debugging/devtools) */
47
+ key: string | undefined;
48
+ /** Optional metadata from effect options */
49
+ meta: EffectMeta | undefined;
50
+ /** The created effect instance */
51
+ instance: Effect;
52
+ }
53
+
54
+ /**
55
+ * Union type for atom/derived/effect creation info.
56
+ */
57
+ export type CreateInfo = MutableInfo | DerivedInfo | EffectInfo;
42
58
 
43
59
  /**
44
60
  * Information provided when a module (via define()) is created.
45
61
  */
46
- export interface ModuleCreateInfo {
62
+ export interface ModuleInfo {
47
63
  /** Discriminator for modules */
48
64
  type: "module";
49
65
  /** Optional key from define options (for debugging/devtools) */
@@ -51,7 +67,7 @@ export interface ModuleCreateInfo {
51
67
  /** Optional metadata from define options */
52
68
  meta: ModuleMeta | undefined;
53
69
  /** The created module instance */
54
- module: unknown;
70
+ instance: unknown;
55
71
  }
56
72
 
57
73
  /**
@@ -62,31 +78,30 @@ export interface ModuleCreateInfo {
62
78
  * - **Debugging** - log atom creation for troubleshooting
63
79
  * - **Testing** - verify expected atoms are created
64
80
  *
81
+ * **IMPORTANT**: Always use `.override()` to preserve the hook chain.
82
+ * Direct assignment to `.current` will break existing handlers.
83
+ *
65
84
  * @example Basic logging
66
85
  * ```ts
67
- * onCreateHook.current = (info) => {
86
+ * onCreateHook.override((prev) => (info) => {
87
+ * prev?.(info); // call existing handlers first
68
88
  * console.log(`Created ${info.type}: ${info.key ?? "anonymous"}`);
69
- * };
89
+ * });
70
90
  * ```
71
91
  *
72
92
  * @example DevTools integration
73
93
  * ```ts
74
- * const atoms = new Map();
75
- * const modules = new Map();
94
+ * const registry = new Map();
76
95
  *
77
- * onCreateHook.current = (info) => {
78
- * if (info.type === "module") {
79
- * modules.set(info.key, info.module);
80
- * } else {
81
- * atoms.set(info.key, info.atom);
82
- * }
83
- * };
96
+ * onCreateHook.override((prev) => (info) => {
97
+ * prev?.(info); // preserve chain
98
+ * registry.set(info.key, info.instance);
99
+ * });
84
100
  * ```
85
101
  *
86
- * @example Cleanup (disable hook)
102
+ * @example Reset to default (disable all handlers)
87
103
  * ```ts
88
- * onCreateHook.current = undefined;
104
+ * onCreateHook.reset();
89
105
  * ```
90
106
  */
91
- export const onCreateHook =
92
- hook<(info: AtomCreateInfo | ModuleCreateInfo) => void>();
107
+ export const onCreateHook = hook<(info: CreateInfo | ModuleInfo) => void>();