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/effect.test.ts
CHANGED
|
@@ -8,8 +8,8 @@ describe("effect", () => {
|
|
|
8
8
|
const effectFn = vi.fn();
|
|
9
9
|
const count$ = atom(0);
|
|
10
10
|
|
|
11
|
-
effect(({
|
|
12
|
-
effectFn(
|
|
11
|
+
effect(({ read }) => {
|
|
12
|
+
effectFn(read(count$));
|
|
13
13
|
});
|
|
14
14
|
|
|
15
15
|
// Wait for async execution
|
|
@@ -21,8 +21,8 @@ describe("effect", () => {
|
|
|
21
21
|
const effectFn = vi.fn();
|
|
22
22
|
const count$ = atom(0);
|
|
23
23
|
|
|
24
|
-
effect(({
|
|
25
|
-
effectFn(
|
|
24
|
+
effect(({ read }) => {
|
|
25
|
+
effectFn(read(count$));
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
await new Promise((r) => setTimeout(r, 0));
|
|
@@ -39,8 +39,8 @@ describe("effect", () => {
|
|
|
39
39
|
const a$ = atom(1);
|
|
40
40
|
const b$ = atom(2);
|
|
41
41
|
|
|
42
|
-
effect(({
|
|
43
|
-
effectFn(
|
|
42
|
+
effect(({ read }) => {
|
|
43
|
+
effectFn(read(a$) + read(b$));
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
await new Promise((r) => setTimeout(r, 0));
|
|
@@ -62,8 +62,8 @@ describe("effect", () => {
|
|
|
62
62
|
const effectFn = vi.fn();
|
|
63
63
|
const count$ = atom(0);
|
|
64
64
|
|
|
65
|
-
effect(({
|
|
66
|
-
effectFn(
|
|
65
|
+
effect(({ read, onCleanup }) => {
|
|
66
|
+
effectFn(read(count$));
|
|
67
67
|
onCleanup(cleanupFn);
|
|
68
68
|
});
|
|
69
69
|
|
|
@@ -81,8 +81,8 @@ describe("effect", () => {
|
|
|
81
81
|
const cleanupFn = vi.fn();
|
|
82
82
|
const count$ = atom(0);
|
|
83
83
|
|
|
84
|
-
const dispose = effect(({
|
|
85
|
-
|
|
84
|
+
const dispose = effect(({ read, onCleanup }) => {
|
|
85
|
+
read(count$);
|
|
86
86
|
onCleanup(cleanupFn);
|
|
87
87
|
});
|
|
88
88
|
|
|
@@ -99,8 +99,8 @@ describe("effect", () => {
|
|
|
99
99
|
const effectFn = vi.fn();
|
|
100
100
|
const count$ = atom(0);
|
|
101
101
|
|
|
102
|
-
const dispose = effect(({
|
|
103
|
-
effectFn(
|
|
102
|
+
const dispose = effect(({ read }) => {
|
|
103
|
+
effectFn(read(count$));
|
|
104
104
|
});
|
|
105
105
|
|
|
106
106
|
await new Promise((r) => setTimeout(r, 0));
|
|
@@ -118,8 +118,8 @@ describe("effect", () => {
|
|
|
118
118
|
const cleanupFn = vi.fn();
|
|
119
119
|
const count$ = atom(0);
|
|
120
120
|
|
|
121
|
-
const dispose = effect(({
|
|
122
|
-
|
|
121
|
+
const dispose = effect(({ read, onCleanup }) => {
|
|
122
|
+
read(count$);
|
|
123
123
|
onCleanup(cleanupFn);
|
|
124
124
|
});
|
|
125
125
|
|
|
@@ -133,16 +133,22 @@ describe("effect", () => {
|
|
|
133
133
|
});
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
describe("error handling", () => {
|
|
137
|
-
it("should
|
|
136
|
+
describe("error handling with safe()", () => {
|
|
137
|
+
it("should catch errors with safe() and return error tuple", async () => {
|
|
138
138
|
const errorHandler = vi.fn();
|
|
139
139
|
const count$ = atom(0);
|
|
140
|
-
const error = new Error("Effect error");
|
|
141
140
|
|
|
142
|
-
effect(({
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
141
|
+
effect(({ read, safe }) => {
|
|
142
|
+
const [err] = safe(() => {
|
|
143
|
+
const count = read(count$);
|
|
144
|
+
if (count > 0) {
|
|
145
|
+
throw new Error("Effect error");
|
|
146
|
+
}
|
|
147
|
+
return count;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (err) {
|
|
151
|
+
errorHandler(err);
|
|
146
152
|
}
|
|
147
153
|
});
|
|
148
154
|
|
|
@@ -151,30 +157,55 @@ describe("effect", () => {
|
|
|
151
157
|
|
|
152
158
|
count$.set(5);
|
|
153
159
|
await new Promise((r) => setTimeout(r, 10));
|
|
154
|
-
expect(errorHandler).toHaveBeenCalledWith(
|
|
160
|
+
expect(errorHandler).toHaveBeenCalledWith(expect.any(Error));
|
|
161
|
+
expect((errorHandler.mock.calls[0][0] as Error).message).toBe(
|
|
162
|
+
"Effect error"
|
|
163
|
+
);
|
|
155
164
|
});
|
|
156
165
|
|
|
157
|
-
it("should
|
|
158
|
-
const
|
|
159
|
-
const count$ = atom(
|
|
160
|
-
const error = new Error("Effect error");
|
|
166
|
+
it("should return success tuple when no error", async () => {
|
|
167
|
+
const results: number[] = [];
|
|
168
|
+
const count$ = atom(5);
|
|
161
169
|
|
|
162
|
-
effect(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
{ onError }
|
|
169
|
-
);
|
|
170
|
+
effect(({ read, safe }) => {
|
|
171
|
+
const [err, value] = safe(() => read(count$) * 2);
|
|
172
|
+
if (!err && value !== undefined) {
|
|
173
|
+
results.push(value);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
170
176
|
|
|
171
177
|
await new Promise((r) => setTimeout(r, 0));
|
|
172
|
-
expect(
|
|
178
|
+
expect(results).toEqual([10]);
|
|
173
179
|
|
|
174
|
-
count$.set(
|
|
180
|
+
count$.set(10);
|
|
175
181
|
await new Promise((r) => setTimeout(r, 10));
|
|
176
|
-
|
|
177
|
-
|
|
182
|
+
expect(results).toEqual([10, 20]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("should preserve Suspense by re-throwing promises in safe()", async () => {
|
|
186
|
+
const effectFn = vi.fn();
|
|
187
|
+
let resolvePromise: (value: number) => void;
|
|
188
|
+
const promise = new Promise<number>((r) => {
|
|
189
|
+
resolvePromise = r;
|
|
190
|
+
});
|
|
191
|
+
const async$ = atom(promise);
|
|
192
|
+
|
|
193
|
+
effect(({ read, safe }) => {
|
|
194
|
+
// safe() should re-throw the promise, not catch it
|
|
195
|
+
const [err, value] = safe(() => read(async$));
|
|
196
|
+
if (!err) {
|
|
197
|
+
effectFn(value);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Effect should not run yet (waiting for promise)
|
|
202
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
203
|
+
expect(effectFn).not.toHaveBeenCalled();
|
|
204
|
+
|
|
205
|
+
// Resolve the promise
|
|
206
|
+
resolvePromise!(42);
|
|
207
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
208
|
+
expect(effectFn).toHaveBeenCalledWith(42);
|
|
178
209
|
});
|
|
179
210
|
});
|
|
180
211
|
|
|
@@ -185,7 +216,7 @@ describe("effect", () => {
|
|
|
185
216
|
const b$ = atom(2);
|
|
186
217
|
|
|
187
218
|
effect(({ all }) => {
|
|
188
|
-
const [a, b] = all(a$, b$);
|
|
219
|
+
const [a, b] = all([a$, b$]);
|
|
189
220
|
effectFn(a + b);
|
|
190
221
|
});
|
|
191
222
|
|
|
@@ -193,4 +224,193 @@ describe("effect", () => {
|
|
|
193
224
|
expect(effectFn).toHaveBeenCalledWith(3);
|
|
194
225
|
});
|
|
195
226
|
});
|
|
227
|
+
|
|
228
|
+
describe("ready() - reactive suspension", () => {
|
|
229
|
+
it("should not run effect when ready() value is null", async () => {
|
|
230
|
+
const effectFn = vi.fn();
|
|
231
|
+
const id$ = atom<string | null>(null);
|
|
232
|
+
|
|
233
|
+
effect(({ ready }) => {
|
|
234
|
+
const id = ready(id$);
|
|
235
|
+
effectFn(id);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
239
|
+
// Effect should not have run because id is null
|
|
240
|
+
expect(effectFn).not.toHaveBeenCalled();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("should run effect when ready() value becomes non-null", async () => {
|
|
244
|
+
const effectFn = vi.fn();
|
|
245
|
+
const id$ = atom<string | null>(null);
|
|
246
|
+
|
|
247
|
+
effect(({ ready }) => {
|
|
248
|
+
const id = ready(id$);
|
|
249
|
+
effectFn(id);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
253
|
+
expect(effectFn).not.toHaveBeenCalled();
|
|
254
|
+
|
|
255
|
+
// Set non-null value
|
|
256
|
+
id$.set("article-123");
|
|
257
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
258
|
+
expect(effectFn).toHaveBeenCalledWith("article-123");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("should re-suspend when ready() value becomes null again", async () => {
|
|
262
|
+
const effectFn = vi.fn();
|
|
263
|
+
const id$ = atom<string | null>("initial");
|
|
264
|
+
|
|
265
|
+
effect(({ ready }) => {
|
|
266
|
+
const id = ready(id$);
|
|
267
|
+
effectFn(id);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
271
|
+
expect(effectFn).toHaveBeenCalledWith("initial");
|
|
272
|
+
expect(effectFn).toHaveBeenCalledTimes(1);
|
|
273
|
+
|
|
274
|
+
// Set to null - effect should not run
|
|
275
|
+
id$.set(null);
|
|
276
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
277
|
+
expect(effectFn).toHaveBeenCalledTimes(1); // Still 1
|
|
278
|
+
|
|
279
|
+
// Set back to non-null
|
|
280
|
+
id$.set("new-value");
|
|
281
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
282
|
+
expect(effectFn).toHaveBeenCalledWith("new-value");
|
|
283
|
+
expect(effectFn).toHaveBeenCalledTimes(2);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("should support ready() with selector", async () => {
|
|
287
|
+
const effectFn = vi.fn();
|
|
288
|
+
const user$ = atom<{ id: number; email: string | null }>({
|
|
289
|
+
id: 1,
|
|
290
|
+
email: null,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
effect(({ ready }) => {
|
|
294
|
+
const email = ready(user$, (u) => u.email);
|
|
295
|
+
effectFn(email);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
299
|
+
expect(effectFn).not.toHaveBeenCalled();
|
|
300
|
+
|
|
301
|
+
// Set email
|
|
302
|
+
user$.set({ id: 1, email: "test@example.com" });
|
|
303
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
304
|
+
expect(effectFn).toHaveBeenCalledWith("test@example.com");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("should run cleanup when transitioning from non-null to null", async () => {
|
|
308
|
+
const cleanupFn = vi.fn();
|
|
309
|
+
const effectFn = vi.fn();
|
|
310
|
+
const id$ = atom<string | null>("initial");
|
|
311
|
+
|
|
312
|
+
effect(({ ready, onCleanup }) => {
|
|
313
|
+
const id = ready(id$);
|
|
314
|
+
effectFn(id);
|
|
315
|
+
onCleanup(cleanupFn);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
319
|
+
expect(effectFn).toHaveBeenCalledWith("initial");
|
|
320
|
+
expect(cleanupFn).not.toHaveBeenCalled();
|
|
321
|
+
|
|
322
|
+
// Set to null - should trigger cleanup from previous run
|
|
323
|
+
id$.set(null);
|
|
324
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
325
|
+
expect(cleanupFn).toHaveBeenCalledTimes(1);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("should work with multiple ready() calls", async () => {
|
|
329
|
+
const effectFn = vi.fn();
|
|
330
|
+
const firstName$ = atom<string | null>(null);
|
|
331
|
+
const lastName$ = atom<string | null>(null);
|
|
332
|
+
|
|
333
|
+
effect(({ ready }) => {
|
|
334
|
+
const first = ready(firstName$);
|
|
335
|
+
const last = ready(lastName$);
|
|
336
|
+
effectFn(`${first} ${last}`);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
340
|
+
expect(effectFn).not.toHaveBeenCalled();
|
|
341
|
+
|
|
342
|
+
// Set only firstName - still suspended
|
|
343
|
+
firstName$.set("John");
|
|
344
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
345
|
+
expect(effectFn).not.toHaveBeenCalled();
|
|
346
|
+
|
|
347
|
+
// Set lastName - effect should run
|
|
348
|
+
lastName$.set("Doe");
|
|
349
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
350
|
+
expect(effectFn).toHaveBeenCalledWith("John Doe");
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("should allow mixing ready() with read()", async () => {
|
|
354
|
+
const effectFn = vi.fn();
|
|
355
|
+
const requiredId$ = atom<string | null>(null);
|
|
356
|
+
const optionalLabel$ = atom("default");
|
|
357
|
+
|
|
358
|
+
effect(({ ready, read }) => {
|
|
359
|
+
const id = ready(requiredId$);
|
|
360
|
+
const label = read(optionalLabel$);
|
|
361
|
+
effectFn({ id, label });
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
365
|
+
expect(effectFn).not.toHaveBeenCalled();
|
|
366
|
+
|
|
367
|
+
// Set required value
|
|
368
|
+
requiredId$.set("123");
|
|
369
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
370
|
+
expect(effectFn).toHaveBeenCalledWith({ id: "123", label: "default" });
|
|
371
|
+
|
|
372
|
+
// Change optional value
|
|
373
|
+
effectFn.mockClear();
|
|
374
|
+
optionalLabel$.set("custom");
|
|
375
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
376
|
+
expect(effectFn).toHaveBeenCalledWith({ id: "123", label: "custom" });
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("should handle real-world: sync to localStorage only when user is logged in", async () => {
|
|
380
|
+
const mockStorage: Record<string, string> = {};
|
|
381
|
+
const currentUser$ = atom<{ id: string } | null>(null);
|
|
382
|
+
const preferences$ = atom({ theme: "dark" });
|
|
383
|
+
|
|
384
|
+
effect(({ ready, read, onCleanup }) => {
|
|
385
|
+
const user = ready(currentUser$);
|
|
386
|
+
const prefs = read(preferences$);
|
|
387
|
+
|
|
388
|
+
// Sync preferences to localStorage for logged-in user
|
|
389
|
+
mockStorage[`prefs:${user.id}`] = JSON.stringify(prefs);
|
|
390
|
+
|
|
391
|
+
onCleanup(() => {
|
|
392
|
+
delete mockStorage[`prefs:${user.id}`];
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
397
|
+
// No user logged in - nothing in storage
|
|
398
|
+
expect(Object.keys(mockStorage)).toHaveLength(0);
|
|
399
|
+
|
|
400
|
+
// User logs in
|
|
401
|
+
currentUser$.set({ id: "u1" });
|
|
402
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
403
|
+
expect(mockStorage["prefs:u1"]).toBe('{"theme":"dark"}');
|
|
404
|
+
|
|
405
|
+
// Preferences change
|
|
406
|
+
preferences$.set({ theme: "light" });
|
|
407
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
408
|
+
expect(mockStorage["prefs:u1"]).toBe('{"theme":"light"}');
|
|
409
|
+
|
|
410
|
+
// User logs out - cleanup runs
|
|
411
|
+
currentUser$.set(null);
|
|
412
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
413
|
+
expect(mockStorage["prefs:u1"]).toBeUndefined();
|
|
414
|
+
});
|
|
415
|
+
});
|
|
196
416
|
});
|
package/src/core/effect.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { batch } from "./batch";
|
|
2
2
|
import { derived } from "./derived";
|
|
3
3
|
import { emitter } from "./emitter";
|
|
4
|
-
import {
|
|
5
|
-
import { SelectContext } from "./select";
|
|
4
|
+
import { ReactiveSelector, SelectContext } from "./select";
|
|
6
5
|
import { EffectOptions } from "./types";
|
|
6
|
+
import { WithReadySelectContext } from "./withReady";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Context object passed to effect functions.
|
|
10
|
-
* Extends `SelectContext` with cleanup
|
|
10
|
+
* Extends `SelectContext` with cleanup utilities.
|
|
11
11
|
*/
|
|
12
|
-
export interface EffectContext extends SelectContext {
|
|
12
|
+
export interface EffectContext extends SelectContext, WithReadySelectContext {
|
|
13
13
|
/**
|
|
14
14
|
* Register a cleanup function that runs before the next execution or on dispose.
|
|
15
15
|
* Multiple cleanup functions can be registered; they run in FIFO order.
|
|
@@ -18,44 +18,22 @@ export interface EffectContext extends SelectContext {
|
|
|
18
18
|
*
|
|
19
19
|
* @example
|
|
20
20
|
* ```ts
|
|
21
|
-
* effect(({
|
|
21
|
+
* effect(({ read, onCleanup }) => {
|
|
22
22
|
* const id = setInterval(() => console.log('tick'), 1000);
|
|
23
23
|
* onCleanup(() => clearInterval(id));
|
|
24
24
|
* });
|
|
25
25
|
* ```
|
|
26
26
|
*/
|
|
27
27
|
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
28
|
}
|
|
45
29
|
|
|
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;
|
|
51
|
-
|
|
52
30
|
/**
|
|
53
31
|
* Creates a side-effect that runs when accessed atom(s) change.
|
|
54
32
|
*
|
|
55
33
|
* Effects are similar to derived atoms but for side-effects rather than computed values.
|
|
56
34
|
* They inherit derived's behavior:
|
|
57
35
|
* - **Suspense-like async**: Waits for async atoms to resolve before running
|
|
58
|
-
* - **Conditional dependencies**: Only tracks atoms actually accessed via `
|
|
36
|
+
* - **Conditional dependencies**: Only tracks atoms actually accessed via `read()`
|
|
59
37
|
* - **Automatic cleanup**: Previous cleanup runs before next execution
|
|
60
38
|
* - **Batched updates**: Atom updates within the effect are batched
|
|
61
39
|
*
|
|
@@ -65,23 +43,23 @@ export type EffectFn = (context: EffectContext) => void;
|
|
|
65
43
|
*
|
|
66
44
|
* ```ts
|
|
67
45
|
* // ❌ WRONG - Don't use async function
|
|
68
|
-
* effect(async ({
|
|
46
|
+
* effect(async ({ read }) => {
|
|
69
47
|
* const data = await fetch('/api');
|
|
70
48
|
* console.log(data);
|
|
71
49
|
* });
|
|
72
50
|
*
|
|
73
|
-
* // ✅ CORRECT - Create async atom and read with
|
|
51
|
+
* // ✅ CORRECT - Create async atom and read with read()
|
|
74
52
|
* const data$ = atom(fetch('/api').then(r => r.json()));
|
|
75
|
-
* effect(({
|
|
76
|
-
* console.log(
|
|
53
|
+
* effect(({ read }) => {
|
|
54
|
+
* console.log(read(data$)); // Suspends until resolved
|
|
77
55
|
* });
|
|
78
56
|
* ```
|
|
79
57
|
*
|
|
80
58
|
* ## Basic Usage
|
|
81
59
|
*
|
|
82
60
|
* ```ts
|
|
83
|
-
* const dispose = effect(({
|
|
84
|
-
* localStorage.setItem('count', String(
|
|
61
|
+
* const dispose = effect(({ read }) => {
|
|
62
|
+
* localStorage.setItem('count', String(read(countAtom)));
|
|
85
63
|
* });
|
|
86
64
|
* ```
|
|
87
65
|
*
|
|
@@ -90,85 +68,85 @@ export type EffectFn = (context: EffectContext) => void;
|
|
|
90
68
|
* Use `onCleanup` to register cleanup functions that run before the next execution or on dispose:
|
|
91
69
|
*
|
|
92
70
|
* ```ts
|
|
93
|
-
* const dispose = effect(({
|
|
94
|
-
* const interval =
|
|
71
|
+
* const dispose = effect(({ read, onCleanup }) => {
|
|
72
|
+
* const interval = read(intervalAtom);
|
|
95
73
|
* const id = setInterval(() => console.log('tick'), interval);
|
|
96
74
|
* onCleanup(() => clearInterval(id));
|
|
97
75
|
* });
|
|
98
76
|
* ```
|
|
99
77
|
*
|
|
100
|
-
* ##
|
|
78
|
+
* ## IMPORTANT: Do NOT Use try/catch - Use safe() Instead
|
|
101
79
|
*
|
|
102
|
-
*
|
|
80
|
+
* **Never wrap `read()` calls in try/catch blocks.** The `read()` function throws
|
|
81
|
+
* Promises when atoms are loading (Suspense pattern). A try/catch will catch
|
|
82
|
+
* these Promises and break the Suspense mechanism.
|
|
103
83
|
*
|
|
104
84
|
* ```ts
|
|
105
|
-
* //
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
85
|
+
* // ❌ WRONG - Catches Suspense Promise, breaks loading state
|
|
86
|
+
* effect(({ read }) => {
|
|
87
|
+
* try {
|
|
88
|
+
* const data = read(asyncAtom$);
|
|
89
|
+
* riskyOperation(data);
|
|
90
|
+
* } catch (e) {
|
|
91
|
+
* console.error(e); // Catches BOTH errors AND loading promises!
|
|
92
|
+
* }
|
|
110
93
|
* });
|
|
111
94
|
*
|
|
112
|
-
* //
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* const
|
|
116
|
-
* riskyOperation(
|
|
117
|
-
* }
|
|
118
|
-
*
|
|
119
|
-
* )
|
|
95
|
+
* // ✅ CORRECT - Use safe() to catch errors but preserve Suspense
|
|
96
|
+
* effect(({ read, safe }) => {
|
|
97
|
+
* const [err, data] = safe(() => {
|
|
98
|
+
* const raw = read(asyncAtom$); // Can throw Promise (Suspense)
|
|
99
|
+
* return riskyOperation(raw); // Can throw Error
|
|
100
|
+
* });
|
|
101
|
+
*
|
|
102
|
+
* if (err) {
|
|
103
|
+
* console.error('Operation failed:', err);
|
|
104
|
+
* return;
|
|
105
|
+
* }
|
|
106
|
+
* // Use data safely
|
|
107
|
+
* });
|
|
120
108
|
* ```
|
|
121
109
|
*
|
|
122
|
-
*
|
|
110
|
+
* The `safe()` utility:
|
|
111
|
+
* - **Catches errors** and returns `[error, undefined]`
|
|
112
|
+
* - **Re-throws Promises** to preserve Suspense behavior
|
|
113
|
+
* - Returns `[undefined, result]` on success
|
|
114
|
+
*
|
|
115
|
+
* @param fn - Effect callback receiving context with `{ read, all, any, race, settled, safe, onCleanup }`.
|
|
123
116
|
* Must be synchronous (not async).
|
|
124
|
-
* @param options - Optional configuration (key
|
|
117
|
+
* @param options - Optional configuration (key)
|
|
125
118
|
* @returns Dispose function to stop the effect and run final cleanup
|
|
126
119
|
* @throws Error if effect function returns a Promise
|
|
127
120
|
*/
|
|
128
|
-
export function effect(
|
|
121
|
+
export function effect(
|
|
122
|
+
fn: ReactiveSelector<void, EffectContext>,
|
|
123
|
+
_options?: EffectOptions
|
|
124
|
+
): VoidFunction {
|
|
129
125
|
let disposed = false;
|
|
130
126
|
const cleanupEmitter = emitter();
|
|
131
|
-
const errorEmitter = emitter<unknown>();
|
|
132
127
|
|
|
133
128
|
// Create a derived atom that runs the effect on each recomputation.
|
|
134
129
|
const derivedAtom = derived((context) => {
|
|
135
130
|
// Run previous cleanup before next execution
|
|
136
|
-
errorEmitter.clear();
|
|
137
131
|
cleanupEmitter.emitAndClear();
|
|
138
132
|
|
|
139
133
|
// Skip effect execution if disposed
|
|
140
134
|
if (disposed) return;
|
|
141
135
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
}
|
|
162
|
-
}
|
|
136
|
+
// Run effect in a batch - multiple atom updates will only notify once
|
|
137
|
+
// Cast to EffectContext since we're adding onCleanup to the DerivedContext
|
|
138
|
+
const effectContext = {
|
|
139
|
+
...context,
|
|
140
|
+
something: true,
|
|
141
|
+
onCleanup: cleanupEmitter.on,
|
|
142
|
+
} as unknown as EffectContext;
|
|
143
|
+
batch(() => fn(effectContext));
|
|
163
144
|
});
|
|
164
145
|
|
|
165
|
-
// Access .
|
|
166
|
-
//
|
|
167
|
-
derivedAtom.
|
|
168
|
-
|
|
169
|
-
options.onError(error);
|
|
170
|
-
}
|
|
171
|
-
// Silently ignore if no error handler
|
|
146
|
+
// Access .get() to trigger initial computation (derived is lazy)
|
|
147
|
+
// Ignore promise rejection - errors should be handled via safe()
|
|
148
|
+
derivedAtom.get().catch(() => {
|
|
149
|
+
// Silently ignore - use safe() for error handling
|
|
172
150
|
});
|
|
173
151
|
|
|
174
152
|
return () => {
|
|
@@ -177,7 +155,6 @@ export function effect(fn: EffectFn, options?: EffectOptions): VoidFunction {
|
|
|
177
155
|
|
|
178
156
|
// Mark as disposed
|
|
179
157
|
disposed = true;
|
|
180
|
-
errorEmitter.clear();
|
|
181
158
|
// Run final cleanup
|
|
182
159
|
cleanupEmitter.emitAndClear();
|
|
183
160
|
};
|
|
@@ -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
|
+
}
|