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.
- package/README.md +867 -160
- package/dist/core/atom.d.ts +83 -6
- package/dist/core/batch.d.ts +3 -3
- package/dist/core/derived.d.ts +55 -21
- package/dist/core/effect.d.ts +47 -51
- package/dist/core/getAtomState.d.ts +29 -0
- package/dist/core/promiseCache.d.ts +23 -32
- package/dist/core/select.d.ts +208 -29
- package/dist/core/types.d.ts +55 -19
- package/dist/core/withReady.d.ts +69 -0
- package/dist/index-CqO6BDwj.cjs +1 -0
- package/dist/index-D8RDOTB_.js +1319 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +9 -7
- package/dist/index.js +12 -10
- package/dist/react/index.cjs +10 -10
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.js +423 -379
- 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 +17 -1
- package/src/core/atom.test.ts +307 -43
- package/src/core/atom.ts +143 -21
- package/src/core/batch.test.ts +10 -10
- package/src/core/batch.ts +3 -3
- package/src/core/derived.test.ts +727 -72
- package/src/core/derived.ts +141 -73
- package/src/core/effect.test.ts +259 -39
- package/src/core/effect.ts +62 -85
- package/src/core/getAtomState.ts +69 -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 +54 -26
- package/src/core/withReady.test.ts +360 -0
- package/src/core/withReady.ts +127 -0
- package/src/core/withUse.ts +1 -1
- package/src/index.test.ts +4 -4
- package/src/index.ts +11 -6
- 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/withReady.test.d.ts} +0 -0
package/src/core/types.ts
CHANGED
|
@@ -28,8 +28,8 @@ export const SYMBOL_DERIVED = Symbol.for("atomirx.derived");
|
|
|
28
28
|
* @example
|
|
29
29
|
* ```ts
|
|
30
30
|
* const enhanced = atom(0)
|
|
31
|
-
* .use(source => ({ ...source, double: () => source.
|
|
32
|
-
* .use(source => ({ ...source, triple: () => source.
|
|
31
|
+
* .use(source => ({ ...source, double: () => source.get() * 2 }))
|
|
32
|
+
* .use(source => ({ ...source, triple: () => source.get() * 3 }));
|
|
33
33
|
* ```
|
|
34
34
|
*/
|
|
35
35
|
export interface Pipeable {
|
|
@@ -61,10 +61,13 @@ export interface AtomMeta {
|
|
|
61
61
|
export interface Atom<T> {
|
|
62
62
|
/** Symbol marker to identify atom instances */
|
|
63
63
|
readonly [SYMBOL_ATOM]: true;
|
|
64
|
-
|
|
65
|
-
readonly value: T;
|
|
64
|
+
|
|
66
65
|
/** Optional metadata for the atom */
|
|
67
66
|
readonly meta?: AtomMeta;
|
|
67
|
+
|
|
68
|
+
/** Get the current value */
|
|
69
|
+
get(): T;
|
|
70
|
+
|
|
68
71
|
/**
|
|
69
72
|
* Subscribe to value changes.
|
|
70
73
|
* @param listener - Callback invoked when value changes
|
|
@@ -91,7 +94,7 @@ export interface Atom<T> {
|
|
|
91
94
|
*
|
|
92
95
|
* // Async value (stores Promise as-is)
|
|
93
96
|
* const posts = atom(fetchPosts());
|
|
94
|
-
* posts.
|
|
97
|
+
* posts.get(); // Promise<Post[]>
|
|
95
98
|
* posts.set(fetchPosts()); // Store new Promise
|
|
96
99
|
* ```
|
|
97
100
|
*/
|
|
@@ -132,7 +135,7 @@ export interface MutableAtom<T> extends Atom<T>, Pipeable {
|
|
|
132
135
|
* A derived (computed) atom that always returns Promise<T> for its value.
|
|
133
136
|
*
|
|
134
137
|
* DerivedAtom computes its value from other atoms. The computation is
|
|
135
|
-
* re-run whenever dependencies change. The `.
|
|
138
|
+
* re-run whenever dependencies change. The `.get()` always returns a Promise,
|
|
136
139
|
* even for synchronous computations.
|
|
137
140
|
*
|
|
138
141
|
* @template T - The resolved type of the computed value
|
|
@@ -141,13 +144,13 @@ export interface MutableAtom<T> extends Atom<T>, Pipeable {
|
|
|
141
144
|
* @example
|
|
142
145
|
* ```ts
|
|
143
146
|
* // Without fallback
|
|
144
|
-
* const double$ = derived(({
|
|
145
|
-
* await double$.
|
|
147
|
+
* const double$ = derived(({ read }) => read(count$) * 2);
|
|
148
|
+
* await double$.get(); // number
|
|
146
149
|
* double$.staleValue; // number | undefined
|
|
147
150
|
* double$.state(); // { status: "ready", value: 10 }
|
|
148
151
|
*
|
|
149
152
|
* // With fallback - during loading
|
|
150
|
-
* const double$ = derived(({
|
|
153
|
+
* const double$ = derived(({ read }) => read(count$) * 2, { fallback: 0 });
|
|
151
154
|
* double$.staleValue; // number (guaranteed)
|
|
152
155
|
* double$.state(); // { status: "loading", promise } during loading
|
|
153
156
|
* ```
|
|
@@ -199,9 +202,48 @@ export type AtomValue<A> =
|
|
|
199
202
|
* @template T - The type of the atom's value
|
|
200
203
|
*/
|
|
201
204
|
export type AtomState<T> =
|
|
202
|
-
| { status: "ready"; value: T }
|
|
203
|
-
| { status: "error"; error: unknown }
|
|
204
|
-
| {
|
|
205
|
+
| { status: "ready"; value: T; error?: undefined; promise?: undefined }
|
|
206
|
+
| { status: "error"; error: unknown; value?: undefined; promise?: undefined }
|
|
207
|
+
| {
|
|
208
|
+
status: "loading";
|
|
209
|
+
promise: Promise<T>;
|
|
210
|
+
value?: undefined;
|
|
211
|
+
error?: undefined;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Result type for SelectContext.state() - simplified AtomState without promise.
|
|
216
|
+
*
|
|
217
|
+
* All properties (`status`, `value`, `error`) are always present:
|
|
218
|
+
* - `value` is `T` when ready, `undefined` otherwise
|
|
219
|
+
* - `error` is the error when errored, `undefined` otherwise
|
|
220
|
+
*
|
|
221
|
+
* This enables easy destructuring without type narrowing:
|
|
222
|
+
* ```ts
|
|
223
|
+
* const { status, value, error } = state(atom$);
|
|
224
|
+
* ```
|
|
225
|
+
*
|
|
226
|
+
* Equality comparisons work correctly (no promise reference issues).
|
|
227
|
+
*/
|
|
228
|
+
export type SelectStateResult<T> =
|
|
229
|
+
| { status: "ready"; value: T; error: undefined }
|
|
230
|
+
| { status: "error"; value: undefined; error: unknown }
|
|
231
|
+
| { status: "loading"; value: undefined; error: undefined };
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Result type for race() and any() - includes winning key.
|
|
235
|
+
*
|
|
236
|
+
* @template K - The key type (string literal union)
|
|
237
|
+
* @template V - The value type
|
|
238
|
+
*/
|
|
239
|
+
export type KeyedResult<K extends string, V> = {
|
|
240
|
+
/** The key that won the race/any */
|
|
241
|
+
key: K;
|
|
242
|
+
/** The resolved value */
|
|
243
|
+
value: V;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
export type AtomPlugin = <T extends Atom<any>>(atom: T) => T | void;
|
|
205
247
|
|
|
206
248
|
/**
|
|
207
249
|
* Result type for settled operations.
|
|
@@ -244,8 +286,6 @@ export interface DerivedOptions<T> {
|
|
|
244
286
|
export interface EffectOptions {
|
|
245
287
|
/** Optional key for debugging */
|
|
246
288
|
key?: string;
|
|
247
|
-
/** Error handler for uncaught errors in the effect */
|
|
248
|
-
onError?: (error: Error) => void;
|
|
249
289
|
}
|
|
250
290
|
|
|
251
291
|
/**
|
|
@@ -297,15 +337,3 @@ export interface ModuleMeta {}
|
|
|
297
337
|
export type Listener<T> = (value: T) => void;
|
|
298
338
|
|
|
299
339
|
export type SingleOrMultipleListeners<T> = Listener<T> | Listener<T>[];
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Type guard to check if a value is an Atom.
|
|
303
|
-
*/
|
|
304
|
-
export declare function isAtom<T>(value: unknown): value is Atom<T>;
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* Type guard to check if a value is a DerivedAtom.
|
|
308
|
-
*/
|
|
309
|
-
export declare function isDerived<T>(
|
|
310
|
-
value: unknown
|
|
311
|
-
): value is DerivedAtom<T, boolean>;
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { withReady } from "./withReady";
|
|
3
|
+
import { atom } from "./atom";
|
|
4
|
+
import { select } from "./select";
|
|
5
|
+
describe("withReady", () => {
|
|
6
|
+
describe("basic functionality", () => {
|
|
7
|
+
it("should add ready method to context", () => {
|
|
8
|
+
select((context) => {
|
|
9
|
+
const enhanced = context.use(withReady());
|
|
10
|
+
expect(typeof enhanced.ready).toBe("function");
|
|
11
|
+
return null;
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("should preserve original context methods", () => {
|
|
16
|
+
select((context) => {
|
|
17
|
+
const enhanced = context.use(withReady());
|
|
18
|
+
expect(typeof enhanced.read).toBe("function");
|
|
19
|
+
expect(typeof enhanced.all).toBe("function");
|
|
20
|
+
expect(typeof enhanced.any).toBe("function");
|
|
21
|
+
expect(typeof enhanced.race).toBe("function");
|
|
22
|
+
expect(typeof enhanced.settled).toBe("function");
|
|
23
|
+
expect(typeof enhanced.safe).toBe("function");
|
|
24
|
+
expect(typeof enhanced.use).toBe("function");
|
|
25
|
+
return null;
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("ready() with non-null values", () => {
|
|
31
|
+
it("should return value when atom has non-null value", () => {
|
|
32
|
+
const count$ = atom(42);
|
|
33
|
+
|
|
34
|
+
const result = select((context) => {
|
|
35
|
+
const ctx = context.use(withReady());
|
|
36
|
+
return ctx.ready(count$);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(result.value).toBe(42);
|
|
40
|
+
expect(result.error).toBeUndefined();
|
|
41
|
+
expect(result.promise).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should return value when atom has zero", () => {
|
|
45
|
+
const count$ = atom(0);
|
|
46
|
+
|
|
47
|
+
const result = select((context) => {
|
|
48
|
+
const ctx = context.use(withReady());
|
|
49
|
+
return ctx.ready(count$);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(result.value).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should return value when atom has empty string", () => {
|
|
56
|
+
const str$ = atom("");
|
|
57
|
+
|
|
58
|
+
const result = select((context) => {
|
|
59
|
+
const ctx = context.use(withReady());
|
|
60
|
+
return ctx.ready(str$);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(result.value).toBe("");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should return value when atom has false", () => {
|
|
67
|
+
const bool$ = atom(false);
|
|
68
|
+
|
|
69
|
+
const result = select((context) => {
|
|
70
|
+
const ctx = context.use(withReady());
|
|
71
|
+
return ctx.ready(bool$);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(result.value).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should return value when atom has object", () => {
|
|
78
|
+
const obj$ = atom({ name: "test" });
|
|
79
|
+
|
|
80
|
+
const result = select((context) => {
|
|
81
|
+
const ctx = context.use(withReady());
|
|
82
|
+
return ctx.ready(obj$);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(result.value).toEqual({ name: "test" });
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("ready() with null/undefined values", () => {
|
|
90
|
+
it("should throw never-resolve promise when atom value is null", () => {
|
|
91
|
+
const nullable$ = atom<string | null>(null);
|
|
92
|
+
|
|
93
|
+
const result = select((context) => {
|
|
94
|
+
const ctx = context.use(withReady());
|
|
95
|
+
return ctx.ready(nullable$);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(result.value).toBeUndefined();
|
|
99
|
+
expect(result.error).toBeUndefined();
|
|
100
|
+
expect(result.promise).toBeInstanceOf(Promise);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should throw never-resolve promise when atom value is undefined", () => {
|
|
104
|
+
const undefinedAtom$ = atom<string | undefined>(undefined);
|
|
105
|
+
|
|
106
|
+
const result = select((context) => {
|
|
107
|
+
const ctx = context.use(withReady());
|
|
108
|
+
return ctx.ready(undefinedAtom$);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(result.value).toBeUndefined();
|
|
112
|
+
expect(result.error).toBeUndefined();
|
|
113
|
+
expect(result.promise).toBeInstanceOf(Promise);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("ready() with selector", () => {
|
|
118
|
+
it("should apply selector and return result when non-null", () => {
|
|
119
|
+
const user$ = atom({ id: 1, name: "John" });
|
|
120
|
+
|
|
121
|
+
const result = select((context) => {
|
|
122
|
+
const ctx = context.use(withReady());
|
|
123
|
+
return ctx.ready(user$, (user) => user.name);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(result.value).toBe("John");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("should throw never-resolve promise when selector returns null", () => {
|
|
130
|
+
const user$ = atom<{ id: number; email: string | null }>({
|
|
131
|
+
id: 1,
|
|
132
|
+
email: null,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const result = select((context) => {
|
|
136
|
+
const ctx = context.use(withReady());
|
|
137
|
+
return ctx.ready(user$, (user) => user.email);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(result.value).toBeUndefined();
|
|
141
|
+
expect(result.promise).toBeInstanceOf(Promise);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("should throw never-resolve promise when selector returns undefined", () => {
|
|
145
|
+
const data$ = atom<{ value?: string }>({});
|
|
146
|
+
|
|
147
|
+
const result = select((context) => {
|
|
148
|
+
const ctx = context.use(withReady());
|
|
149
|
+
return ctx.ready(data$, (data) => data.value);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(result.value).toBeUndefined();
|
|
153
|
+
expect(result.promise).toBeInstanceOf(Promise);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("should return zero from selector", () => {
|
|
157
|
+
const data$ = atom({ count: 0 });
|
|
158
|
+
|
|
159
|
+
const result = select((context) => {
|
|
160
|
+
const ctx = context.use(withReady());
|
|
161
|
+
return ctx.ready(data$, (data) => data.count);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(result.value).toBe(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("should return empty string from selector", () => {
|
|
168
|
+
const data$ = atom({ name: "" });
|
|
169
|
+
|
|
170
|
+
const result = select((context) => {
|
|
171
|
+
const ctx = context.use(withReady());
|
|
172
|
+
return ctx.ready(data$, (data) => data.name);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(result.value).toBe("");
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("dependency tracking", () => {
|
|
180
|
+
it("should track atom as dependency", () => {
|
|
181
|
+
const count$ = atom(42);
|
|
182
|
+
|
|
183
|
+
const result = select((context) => {
|
|
184
|
+
const ctx = context.use(withReady());
|
|
185
|
+
return ctx.ready(count$);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(result.dependencies.has(count$)).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("should track atom as dependency even when throwing promise", () => {
|
|
192
|
+
const nullable$ = atom<string | null>(null);
|
|
193
|
+
|
|
194
|
+
const result = select((context) => {
|
|
195
|
+
const ctx = context.use(withReady());
|
|
196
|
+
return ctx.ready(nullable$);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(result.dependencies.has(nullable$)).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("never-resolve promise behavior", () => {
|
|
204
|
+
it("should return a promise that never resolves", async () => {
|
|
205
|
+
const nullable$ = atom<string | null>(null);
|
|
206
|
+
|
|
207
|
+
const result = select((context) => {
|
|
208
|
+
const ctx = context.use(withReady());
|
|
209
|
+
return ctx.ready(nullable$);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// The promise should never resolve
|
|
213
|
+
// We test this by racing with a timeout
|
|
214
|
+
const timeoutPromise = new Promise<"timeout">((resolve) =>
|
|
215
|
+
setTimeout(() => resolve("timeout"), 50)
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const raceResult = await Promise.race([result.promise, timeoutPromise]);
|
|
219
|
+
expect(raceResult).toBe("timeout");
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("ready() with async selector", () => {
|
|
224
|
+
it("should suspend when selector returns a pending promise", () => {
|
|
225
|
+
const data$ = atom({ id: 1 });
|
|
226
|
+
const pendingPromise = new Promise<string>(() => {
|
|
227
|
+
// Never resolves - stays pending
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const result = select((context) => {
|
|
231
|
+
const ctx = context.use(withReady());
|
|
232
|
+
return ctx.ready(data$, () => pendingPromise);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Should suspend with the tracked promise
|
|
236
|
+
expect(result.value).toBeUndefined();
|
|
237
|
+
expect(result.error).toBeUndefined();
|
|
238
|
+
expect(result.promise).toBeInstanceOf(Promise);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("should return value when selector returns a resolved promise", async () => {
|
|
242
|
+
const data$ = atom({ id: 1 });
|
|
243
|
+
const resolvedPromise = Promise.resolve("async result");
|
|
244
|
+
|
|
245
|
+
// First call to track the promise and trigger state tracking
|
|
246
|
+
select((context) => {
|
|
247
|
+
const ctx = context.use(withReady());
|
|
248
|
+
return ctx.ready(data$, () => resolvedPromise);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Wait for the promise .then() handlers to run and update cache
|
|
252
|
+
await resolvedPromise;
|
|
253
|
+
await new Promise<void>((r) => queueMicrotask(() => r()));
|
|
254
|
+
|
|
255
|
+
// Second call - promise should now be fulfilled in cache
|
|
256
|
+
const result = select((context) => {
|
|
257
|
+
const ctx = context.use(withReady());
|
|
258
|
+
return ctx.ready(data$, () => resolvedPromise);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(result.value).toBe("async result");
|
|
262
|
+
expect(result.error).toBeUndefined();
|
|
263
|
+
expect(result.promise).toBeUndefined();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("should throw error when selector returns a rejected promise", async () => {
|
|
267
|
+
const data$ = atom({ id: 1 });
|
|
268
|
+
const testError = new Error("async error");
|
|
269
|
+
const rejectedPromise = Promise.reject(testError);
|
|
270
|
+
|
|
271
|
+
// Prevent unhandled rejection warning
|
|
272
|
+
rejectedPromise.catch(() => {});
|
|
273
|
+
|
|
274
|
+
// First call to track the promise
|
|
275
|
+
select((context) => {
|
|
276
|
+
const ctx = context.use(withReady());
|
|
277
|
+
return ctx.ready(data$, () => rejectedPromise);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Wait for the promise rejection handlers to run
|
|
281
|
+
await new Promise<void>((r) => queueMicrotask(() => r()));
|
|
282
|
+
await new Promise<void>((r) => queueMicrotask(() => r()));
|
|
283
|
+
|
|
284
|
+
// Second call - promise should now be rejected in cache
|
|
285
|
+
const result = select((context) => {
|
|
286
|
+
const ctx = context.use(withReady());
|
|
287
|
+
return ctx.ready(data$, () => rejectedPromise);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
expect(result.value).toBeUndefined();
|
|
291
|
+
expect(result.error).toBe(testError);
|
|
292
|
+
expect(result.promise).toBeUndefined();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("should return null when async selector resolves to null (bypasses null check)", async () => {
|
|
296
|
+
const data$ = atom({ id: 1 });
|
|
297
|
+
const resolvedToNull = Promise.resolve(null);
|
|
298
|
+
|
|
299
|
+
// First call to track the promise
|
|
300
|
+
select((context) => {
|
|
301
|
+
const ctx = context.use(withReady());
|
|
302
|
+
return ctx.ready(data$, () => resolvedToNull);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Wait for the promise to be tracked as fulfilled
|
|
306
|
+
await resolvedToNull;
|
|
307
|
+
await new Promise<void>((r) => queueMicrotask(() => r()));
|
|
308
|
+
|
|
309
|
+
// Second call - promise should now be fulfilled in cache
|
|
310
|
+
const result = select((context) => {
|
|
311
|
+
const ctx = context.use(withReady());
|
|
312
|
+
return ctx.ready(data$, () => resolvedToNull);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Async selectors bypass null/undefined checking - value is returned as-is
|
|
316
|
+
expect(result.value).toBe(null);
|
|
317
|
+
expect(result.error).toBeUndefined();
|
|
318
|
+
expect(result.promise).toBeUndefined();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("should return undefined when async selector resolves to undefined (bypasses undefined check)", async () => {
|
|
322
|
+
const data$ = atom({ id: 1 });
|
|
323
|
+
const resolvedToUndefined = Promise.resolve(undefined);
|
|
324
|
+
|
|
325
|
+
// First call to track the promise
|
|
326
|
+
select((context) => {
|
|
327
|
+
const ctx = context.use(withReady());
|
|
328
|
+
return ctx.ready(data$, () => resolvedToUndefined);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Wait for the promise to be tracked as fulfilled
|
|
332
|
+
await resolvedToUndefined;
|
|
333
|
+
await new Promise<void>((r) => queueMicrotask(() => r()));
|
|
334
|
+
|
|
335
|
+
// Second call - promise should now be fulfilled in cache
|
|
336
|
+
const result = select((context) => {
|
|
337
|
+
const ctx = context.use(withReady());
|
|
338
|
+
return ctx.ready(data$, () => resolvedToUndefined);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Async selectors bypass null/undefined checking - value is returned as-is
|
|
342
|
+
expect(result.value).toBe(undefined);
|
|
343
|
+
expect(result.error).toBeUndefined();
|
|
344
|
+
expect(result.promise).toBeUndefined();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("should track atom as dependency when using async selector", () => {
|
|
348
|
+
const data$ = atom({ id: 1 });
|
|
349
|
+
const pendingPromise = new Promise<string>(() => {});
|
|
350
|
+
|
|
351
|
+
const result = select((context) => {
|
|
352
|
+
const ctx = context.use(withReady());
|
|
353
|
+
return ctx.ready(data$, () => pendingPromise);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Dependency is tracked even when suspending
|
|
357
|
+
expect(result.dependencies.has(data$)).toBe(true);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { isPromiseLike } from "./isPromiseLike";
|
|
2
|
+
import { trackPromise } from "./promiseCache";
|
|
3
|
+
import { SelectContext } from "./select";
|
|
4
|
+
import { Atom } from "./types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Extension interface that adds `ready()` method to SelectContext.
|
|
8
|
+
* Used in derived atoms and effects to wait for non-null values.
|
|
9
|
+
*/
|
|
10
|
+
export interface WithReadySelectContext {
|
|
11
|
+
/**
|
|
12
|
+
* Wait for an atom to have a non-null/non-undefined value.
|
|
13
|
+
*
|
|
14
|
+
* If the value is null/undefined, the computation suspends until the atom
|
|
15
|
+
* changes to a non-null value, then automatically resumes.
|
|
16
|
+
*
|
|
17
|
+
* **IMPORTANT: Only use in `derived()` or `effect()` context**
|
|
18
|
+
*
|
|
19
|
+
* @param atom - The atom to read and wait for
|
|
20
|
+
* @returns The non-null value (type excludes null | undefined)
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* // Wait for currentArticleId to be set before computing
|
|
25
|
+
* const currentArticle$ = derived(({ ready, read }) => {
|
|
26
|
+
* const id = ready(currentArticleId$); // Suspends if null
|
|
27
|
+
* const cache = read(articleCache$);
|
|
28
|
+
* return cache[id];
|
|
29
|
+
* });
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
ready<T>(
|
|
33
|
+
atom: Atom<T>
|
|
34
|
+
): T extends PromiseLike<any> ? never : Exclude<T, null | undefined>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Wait for a selected value from an atom to be non-null/non-undefined.
|
|
38
|
+
*
|
|
39
|
+
* If the selected value is null/undefined, the computation suspends until the
|
|
40
|
+
* selected value changes to a non-null value, then automatically resumes.
|
|
41
|
+
*
|
|
42
|
+
* **IMPORTANT: Only use in `derived()` or `effect()` context**
|
|
43
|
+
*
|
|
44
|
+
* @param atom - The atom to read
|
|
45
|
+
* @param selector - Function to extract/transform the value
|
|
46
|
+
* @returns The non-null selected value
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* // Wait for user's email to be set
|
|
51
|
+
* const emailDerived$ = derived(({ ready }) => {
|
|
52
|
+
* const email = ready(user$, u => u.email); // Suspends if email is null
|
|
53
|
+
* return `Contact: ${email}`;
|
|
54
|
+
* });
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
ready<T, R>(
|
|
58
|
+
atom: Atom<T>,
|
|
59
|
+
selector: (current: Awaited<T>) => R
|
|
60
|
+
): R extends PromiseLike<any> ? never : Exclude<R, null | undefined>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Internal helper that suspends computation if value is null/undefined.
|
|
65
|
+
*/
|
|
66
|
+
function waitForValue<T>(value: T): Exclude<T, null | undefined> {
|
|
67
|
+
if (value === undefined || value === null) {
|
|
68
|
+
throw new Promise(() => {});
|
|
69
|
+
}
|
|
70
|
+
return value as Exclude<T, null | undefined>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Plugin that adds `ready()` method to a SelectContext.
|
|
75
|
+
*
|
|
76
|
+
* `ready()` enables a "reactive suspension" pattern where derived atoms
|
|
77
|
+
* wait for required values before computing. This is useful for:
|
|
78
|
+
*
|
|
79
|
+
* - Route-based entity loading (`/article/:id` - wait for ID to be set)
|
|
80
|
+
* - Authentication-gated content (wait for user to be logged in)
|
|
81
|
+
* - Conditional data dependencies (wait for prerequisite data)
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* // Used internally by derived() - you don't need to call this directly
|
|
86
|
+
* const result = select((context) => fn(context.use(withReady())));
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export function withReady() {
|
|
90
|
+
return <TContext extends SelectContext>(
|
|
91
|
+
context: TContext
|
|
92
|
+
): TContext & WithReadySelectContext => {
|
|
93
|
+
return {
|
|
94
|
+
...context,
|
|
95
|
+
ready: (atom: Atom<any>, selector?: (current: any) => any): any => {
|
|
96
|
+
const value = context.read(atom);
|
|
97
|
+
// we allow selector to return a promise, and wait for that promise if it is not resolved yet
|
|
98
|
+
const selected = selector ? selector(value) : value;
|
|
99
|
+
// Handle async selectors: when the selector returns a Promise,
|
|
100
|
+
// we track its state and handle suspension/resolution accordingly
|
|
101
|
+
if (isPromiseLike(selected)) {
|
|
102
|
+
const p = trackPromise(selected);
|
|
103
|
+
|
|
104
|
+
// Promise is still pending - suspend computation by throwing
|
|
105
|
+
// the tracked promise. This enables React Suspense integration.
|
|
106
|
+
if (p.status === "pending") {
|
|
107
|
+
throw p.promise;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Promise resolved successfully - return the resolved value.
|
|
111
|
+
// Note: This bypasses null/undefined checking for async results,
|
|
112
|
+
// allowing async selectors to return any value including null.
|
|
113
|
+
if (p.status === "fulfilled") {
|
|
114
|
+
return p.value;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Promise rejected - propagate the error
|
|
118
|
+
throw p.error;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// For sync values (no selector, or selector returned sync value),
|
|
122
|
+
// check for null/undefined and suspend if not ready
|
|
123
|
+
return waitForValue(selected);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
}
|
package/src/core/withUse.ts
CHANGED
|
@@ -38,7 +38,7 @@ import type { Pipeable } from "./types";
|
|
|
38
38
|
*/
|
|
39
39
|
export function withUse<TSource extends object>(source: TSource) {
|
|
40
40
|
return Object.assign(source, {
|
|
41
|
-
use<TNew = void>(plugin: (source: TSource) => TNew): any {
|
|
41
|
+
use<TNew = void>(plugin: (source: NoInfer<TSource>) => TNew): any {
|
|
42
42
|
const result = plugin(source);
|
|
43
43
|
// Void/falsy: return original source (side-effect only plugins)
|
|
44
44
|
if (!result) return source;
|
package/src/index.test.ts
CHANGED
|
@@ -17,7 +17,7 @@ describe("atomirx exports", () => {
|
|
|
17
17
|
it("should export atom", () => {
|
|
18
18
|
expect(typeof atom).toBe("function");
|
|
19
19
|
const count = atom(0);
|
|
20
|
-
expect(count.
|
|
20
|
+
expect(count.get()).toBe(0);
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
it("should export batch", () => {
|
|
@@ -31,8 +31,8 @@ describe("atomirx exports", () => {
|
|
|
31
31
|
it("should export derived", async () => {
|
|
32
32
|
expect(typeof derived).toBe("function");
|
|
33
33
|
const count = atom(5);
|
|
34
|
-
const doubled = derived(({
|
|
35
|
-
expect(await doubled.
|
|
34
|
+
const doubled = derived(({ read }) => read(count) * 2);
|
|
35
|
+
expect(await doubled.get()).toBe(10);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
it("should export effect", () => {
|
|
@@ -53,7 +53,7 @@ describe("atomirx exports", () => {
|
|
|
53
53
|
it("should export isDerived", () => {
|
|
54
54
|
expect(typeof isDerived).toBe("function");
|
|
55
55
|
const count = atom(0);
|
|
56
|
-
const doubled = derived(({
|
|
56
|
+
const doubled = derived(({ read }) => read(count) * 2);
|
|
57
57
|
expect(isDerived(count)).toBe(false);
|
|
58
58
|
expect(isDerived(doubled)).toBe(true);
|
|
59
59
|
});
|