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.
- package/README.md +868 -161
- package/coverage/src/core/onCreateHook.ts.html +72 -70
- package/dist/core/atom.d.ts +83 -6
- package/dist/core/batch.d.ts +3 -3
- package/dist/core/derived.d.ts +69 -22
- package/dist/core/effect.d.ts +52 -52
- package/dist/core/getAtomState.d.ts +29 -0
- package/dist/core/hook.d.ts +1 -1
- package/dist/core/onCreateHook.d.ts +37 -23
- package/dist/core/onErrorHook.d.ts +49 -0
- package/dist/core/promiseCache.d.ts +23 -32
- package/dist/core/select.d.ts +208 -29
- package/dist/core/types.d.ts +107 -22
- package/dist/core/withReady.d.ts +115 -0
- package/dist/core/withReady.test.d.ts +1 -0
- package/dist/index-CBVj1kSj.js +1350 -0
- package/dist/index-Cxk9v0um.cjs +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +12 -8
- package/dist/index.js +18 -15
- package/dist/react/index.cjs +10 -10
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.js +422 -377
- package/dist/react/rx.d.ts +114 -25
- package/dist/react/useAction.d.ts +5 -4
- package/dist/react/{useValue.d.ts → useSelector.d.ts} +56 -25
- package/dist/react/useSelector.test.d.ts +1 -0
- package/package.json +1 -1
- package/src/core/atom.test.ts +307 -43
- package/src/core/atom.ts +144 -22
- package/src/core/batch.test.ts +10 -10
- package/src/core/batch.ts +3 -3
- package/src/core/define.test.ts +12 -11
- package/src/core/define.ts +1 -1
- package/src/core/derived.test.ts +906 -72
- package/src/core/derived.ts +192 -81
- package/src/core/effect.test.ts +651 -45
- package/src/core/effect.ts +102 -98
- package/src/core/getAtomState.ts +69 -0
- package/src/core/hook.test.ts +5 -5
- package/src/core/hook.ts +1 -1
- package/src/core/onCreateHook.ts +38 -23
- package/src/core/onErrorHook.test.ts +350 -0
- package/src/core/onErrorHook.ts +52 -0
- package/src/core/promiseCache.test.ts +5 -3
- package/src/core/promiseCache.ts +76 -71
- package/src/core/select.ts +405 -130
- package/src/core/selector.test.ts +574 -32
- package/src/core/types.ts +107 -29
- package/src/core/withReady.test.ts +534 -0
- package/src/core/withReady.ts +191 -0
- package/src/core/withUse.ts +1 -1
- package/src/index.test.ts +4 -4
- package/src/index.ts +21 -7
- package/src/react/index.ts +2 -1
- package/src/react/rx.test.tsx +173 -18
- package/src/react/rx.tsx +274 -43
- package/src/react/useAction.test.ts +12 -14
- package/src/react/useAction.ts +11 -9
- package/src/react/{useValue.test.ts → useSelector.test.ts} +16 -16
- package/src/react/{useValue.ts → useSelector.ts} +64 -33
- package/v2.md +44 -44
- package/dist/index-2ok7ilik.js +0 -1217
- package/dist/index-B_5SFzfl.cjs +0 -1
- /package/dist/{react/useValue.test.d.ts → core/onErrorHook.test.d.ts} +0 -0
package/src/core/derived.ts
CHANGED
|
@@ -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: `{
|
|
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 `.
|
|
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 ({
|
|
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(({
|
|
59
|
+
* derived(({ read }) => fetch('/api').then(r => r.json()));
|
|
45
60
|
*
|
|
46
|
-
* // ✅ CORRECT - Create async atom and read with
|
|
61
|
+
* // ✅ CORRECT - Create async atom and read with read()
|
|
47
62
|
* const data$ = atom(fetch('/api').then(r => r.json()));
|
|
48
|
-
* derived(({
|
|
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**: `.
|
|
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**: `
|
|
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
|
|
109
|
+
* ## Suspense-Style read()
|
|
62
110
|
*
|
|
63
|
-
* The `
|
|
64
|
-
* - If source atom is **loading**: `
|
|
65
|
-
* - If source atom has **error**: `
|
|
66
|
-
* - If source atom has **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(({
|
|
126
|
+
* const doubled$ = derived(({ read }) => read(count$) * 2);
|
|
79
127
|
*
|
|
80
|
-
* await doubled$.
|
|
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(({
|
|
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(({
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
369
|
+
* Get the computed value as a Promise.
|
|
262
370
|
* Always returns Promise<T>, even for sync computations.
|
|
263
371
|
*/
|
|
264
|
-
get
|
|
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
|
-
//
|
|
331
|
-
|
|
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
|
-
|
|
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
|
}
|