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/effect.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { atom } from "./atom";
|
|
3
|
-
import { effect } from "./effect";
|
|
3
|
+
import { effect, Effect } from "./effect";
|
|
4
|
+
import { onCreateHook } from "./onCreateHook";
|
|
4
5
|
|
|
5
6
|
describe("effect", () => {
|
|
6
7
|
describe("basic functionality", () => {
|
|
@@ -8,8 +9,8 @@ describe("effect", () => {
|
|
|
8
9
|
const effectFn = vi.fn();
|
|
9
10
|
const count$ = atom(0);
|
|
10
11
|
|
|
11
|
-
effect(({
|
|
12
|
-
effectFn(
|
|
12
|
+
effect(({ read }) => {
|
|
13
|
+
effectFn(read(count$));
|
|
13
14
|
});
|
|
14
15
|
|
|
15
16
|
// Wait for async execution
|
|
@@ -21,8 +22,8 @@ describe("effect", () => {
|
|
|
21
22
|
const effectFn = vi.fn();
|
|
22
23
|
const count$ = atom(0);
|
|
23
24
|
|
|
24
|
-
effect(({
|
|
25
|
-
effectFn(
|
|
25
|
+
effect(({ read }) => {
|
|
26
|
+
effectFn(read(count$));
|
|
26
27
|
});
|
|
27
28
|
|
|
28
29
|
await new Promise((r) => setTimeout(r, 0));
|
|
@@ -39,8 +40,8 @@ describe("effect", () => {
|
|
|
39
40
|
const a$ = atom(1);
|
|
40
41
|
const b$ = atom(2);
|
|
41
42
|
|
|
42
|
-
effect(({
|
|
43
|
-
effectFn(
|
|
43
|
+
effect(({ read }) => {
|
|
44
|
+
effectFn(read(a$) + read(b$));
|
|
44
45
|
});
|
|
45
46
|
|
|
46
47
|
await new Promise((r) => setTimeout(r, 0));
|
|
@@ -62,8 +63,8 @@ describe("effect", () => {
|
|
|
62
63
|
const effectFn = vi.fn();
|
|
63
64
|
const count$ = atom(0);
|
|
64
65
|
|
|
65
|
-
effect(({
|
|
66
|
-
effectFn(
|
|
66
|
+
effect(({ read, onCleanup }) => {
|
|
67
|
+
effectFn(read(count$));
|
|
67
68
|
onCleanup(cleanupFn);
|
|
68
69
|
});
|
|
69
70
|
|
|
@@ -81,15 +82,15 @@ describe("effect", () => {
|
|
|
81
82
|
const cleanupFn = vi.fn();
|
|
82
83
|
const count$ = atom(0);
|
|
83
84
|
|
|
84
|
-
const
|
|
85
|
-
|
|
85
|
+
const e = effect(({ read, onCleanup }) => {
|
|
86
|
+
read(count$);
|
|
86
87
|
onCleanup(cleanupFn);
|
|
87
88
|
});
|
|
88
89
|
|
|
89
90
|
await new Promise((r) => setTimeout(r, 0));
|
|
90
91
|
expect(cleanupFn).not.toHaveBeenCalled();
|
|
91
92
|
|
|
92
|
-
dispose();
|
|
93
|
+
e.dispose();
|
|
93
94
|
expect(cleanupFn).toHaveBeenCalledTimes(1);
|
|
94
95
|
});
|
|
95
96
|
});
|
|
@@ -99,14 +100,14 @@ describe("effect", () => {
|
|
|
99
100
|
const effectFn = vi.fn();
|
|
100
101
|
const count$ = atom(0);
|
|
101
102
|
|
|
102
|
-
const
|
|
103
|
-
effectFn(
|
|
103
|
+
const e = effect(({ read }) => {
|
|
104
|
+
effectFn(read(count$));
|
|
104
105
|
});
|
|
105
106
|
|
|
106
107
|
await new Promise((r) => setTimeout(r, 0));
|
|
107
108
|
expect(effectFn).toHaveBeenCalledTimes(1);
|
|
108
109
|
|
|
109
|
-
dispose();
|
|
110
|
+
e.dispose();
|
|
110
111
|
|
|
111
112
|
count$.set(5);
|
|
112
113
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -118,31 +119,37 @@ describe("effect", () => {
|
|
|
118
119
|
const cleanupFn = vi.fn();
|
|
119
120
|
const count$ = atom(0);
|
|
120
121
|
|
|
121
|
-
const
|
|
122
|
-
|
|
122
|
+
const e = effect(({ read, onCleanup }) => {
|
|
123
|
+
read(count$);
|
|
123
124
|
onCleanup(cleanupFn);
|
|
124
125
|
});
|
|
125
126
|
|
|
126
127
|
await new Promise((r) => setTimeout(r, 0));
|
|
127
128
|
|
|
128
|
-
dispose();
|
|
129
|
+
e.dispose();
|
|
129
130
|
expect(cleanupFn).toHaveBeenCalledTimes(1);
|
|
130
131
|
|
|
131
|
-
dispose(); // Second call should be no-op
|
|
132
|
+
e.dispose(); // Second call should be no-op
|
|
132
133
|
expect(cleanupFn).toHaveBeenCalledTimes(1);
|
|
133
134
|
});
|
|
134
135
|
});
|
|
135
136
|
|
|
136
|
-
describe("error handling", () => {
|
|
137
|
-
it("should
|
|
137
|
+
describe("error handling with safe()", () => {
|
|
138
|
+
it("should catch errors with safe() and return error tuple", async () => {
|
|
138
139
|
const errorHandler = vi.fn();
|
|
139
140
|
const count$ = atom(0);
|
|
140
|
-
const error = new Error("Effect error");
|
|
141
141
|
|
|
142
|
-
effect(({
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
142
|
+
effect(({ read, safe }) => {
|
|
143
|
+
const [err] = safe(() => {
|
|
144
|
+
const count = read(count$);
|
|
145
|
+
if (count > 0) {
|
|
146
|
+
throw new Error("Effect error");
|
|
147
|
+
}
|
|
148
|
+
return count;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (err) {
|
|
152
|
+
errorHandler(err);
|
|
146
153
|
}
|
|
147
154
|
});
|
|
148
155
|
|
|
@@ -151,30 +158,55 @@ describe("effect", () => {
|
|
|
151
158
|
|
|
152
159
|
count$.set(5);
|
|
153
160
|
await new Promise((r) => setTimeout(r, 10));
|
|
154
|
-
expect(errorHandler).toHaveBeenCalledWith(
|
|
161
|
+
expect(errorHandler).toHaveBeenCalledWith(expect.any(Error));
|
|
162
|
+
expect((errorHandler.mock.calls[0][0] as Error).message).toBe(
|
|
163
|
+
"Effect error"
|
|
164
|
+
);
|
|
155
165
|
});
|
|
156
166
|
|
|
157
|
-
it("should
|
|
158
|
-
const
|
|
159
|
-
const count$ = atom(
|
|
160
|
-
const error = new Error("Effect error");
|
|
167
|
+
it("should return success tuple when no error", async () => {
|
|
168
|
+
const results: number[] = [];
|
|
169
|
+
const count$ = atom(5);
|
|
161
170
|
|
|
162
|
-
effect(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
{ onError }
|
|
169
|
-
);
|
|
171
|
+
effect(({ read, safe }) => {
|
|
172
|
+
const [err, value] = safe(() => read(count$) * 2);
|
|
173
|
+
if (!err && value !== undefined) {
|
|
174
|
+
results.push(value);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
170
177
|
|
|
171
178
|
await new Promise((r) => setTimeout(r, 0));
|
|
172
|
-
expect(
|
|
179
|
+
expect(results).toEqual([10]);
|
|
173
180
|
|
|
174
|
-
count$.set(
|
|
181
|
+
count$.set(10);
|
|
182
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
183
|
+
expect(results).toEqual([10, 20]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("should preserve Suspense by re-throwing promises in safe()", async () => {
|
|
187
|
+
const effectFn = vi.fn();
|
|
188
|
+
let resolvePromise: (value: number) => void;
|
|
189
|
+
const promise = new Promise<number>((r) => {
|
|
190
|
+
resolvePromise = r;
|
|
191
|
+
});
|
|
192
|
+
const async$ = atom(promise);
|
|
193
|
+
|
|
194
|
+
effect(({ read, safe }) => {
|
|
195
|
+
// safe() should re-throw the promise, not catch it
|
|
196
|
+
const [err, value] = safe(() => read(async$));
|
|
197
|
+
if (!err) {
|
|
198
|
+
effectFn(value);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Effect should not run yet (waiting for promise)
|
|
203
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
204
|
+
expect(effectFn).not.toHaveBeenCalled();
|
|
205
|
+
|
|
206
|
+
// Resolve the promise
|
|
207
|
+
resolvePromise!(42);
|
|
175
208
|
await new Promise((r) => setTimeout(r, 10));
|
|
176
|
-
|
|
177
|
-
expect(onError).toHaveBeenCalledWith(error);
|
|
209
|
+
expect(effectFn).toHaveBeenCalledWith(42);
|
|
178
210
|
});
|
|
179
211
|
});
|
|
180
212
|
|
|
@@ -185,7 +217,7 @@ describe("effect", () => {
|
|
|
185
217
|
const b$ = atom(2);
|
|
186
218
|
|
|
187
219
|
effect(({ all }) => {
|
|
188
|
-
const [a, b] = all(a$, b$);
|
|
220
|
+
const [a, b] = all([a$, b$]);
|
|
189
221
|
effectFn(a + b);
|
|
190
222
|
});
|
|
191
223
|
|
|
@@ -193,4 +225,578 @@ describe("effect", () => {
|
|
|
193
225
|
expect(effectFn).toHaveBeenCalledWith(3);
|
|
194
226
|
});
|
|
195
227
|
});
|
|
228
|
+
|
|
229
|
+
describe("ready() - reactive suspension", () => {
|
|
230
|
+
it("should not run effect when ready() value is null", async () => {
|
|
231
|
+
const effectFn = vi.fn();
|
|
232
|
+
const id$ = atom<string | null>(null);
|
|
233
|
+
|
|
234
|
+
effect(({ ready }) => {
|
|
235
|
+
const id = ready(id$);
|
|
236
|
+
effectFn(id);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
240
|
+
// Effect should not have run because id is null
|
|
241
|
+
expect(effectFn).not.toHaveBeenCalled();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("should run effect when ready() value becomes non-null", async () => {
|
|
245
|
+
const effectFn = vi.fn();
|
|
246
|
+
const id$ = atom<string | null>(null);
|
|
247
|
+
|
|
248
|
+
effect(({ ready }) => {
|
|
249
|
+
const id = ready(id$);
|
|
250
|
+
effectFn(id);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
254
|
+
expect(effectFn).not.toHaveBeenCalled();
|
|
255
|
+
|
|
256
|
+
// Set non-null value
|
|
257
|
+
id$.set("article-123");
|
|
258
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
259
|
+
expect(effectFn).toHaveBeenCalledWith("article-123");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("should re-suspend when ready() value becomes null again", async () => {
|
|
263
|
+
const effectFn = vi.fn();
|
|
264
|
+
const id$ = atom<string | null>("initial");
|
|
265
|
+
|
|
266
|
+
effect(({ ready }) => {
|
|
267
|
+
const id = ready(id$);
|
|
268
|
+
effectFn(id);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
272
|
+
expect(effectFn).toHaveBeenCalledWith("initial");
|
|
273
|
+
expect(effectFn).toHaveBeenCalledTimes(1);
|
|
274
|
+
|
|
275
|
+
// Set to null - effect should not run
|
|
276
|
+
id$.set(null);
|
|
277
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
278
|
+
expect(effectFn).toHaveBeenCalledTimes(1); // Still 1
|
|
279
|
+
|
|
280
|
+
// Set back to non-null
|
|
281
|
+
id$.set("new-value");
|
|
282
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
283
|
+
expect(effectFn).toHaveBeenCalledWith("new-value");
|
|
284
|
+
expect(effectFn).toHaveBeenCalledTimes(2);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("should support ready() with selector", async () => {
|
|
288
|
+
const effectFn = vi.fn();
|
|
289
|
+
const user$ = atom<{ id: number; email: string | null }>({
|
|
290
|
+
id: 1,
|
|
291
|
+
email: null,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
effect(({ ready }) => {
|
|
295
|
+
const email = ready(user$, (u) => u.email);
|
|
296
|
+
effectFn(email);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
300
|
+
expect(effectFn).not.toHaveBeenCalled();
|
|
301
|
+
|
|
302
|
+
// Set email
|
|
303
|
+
user$.set({ id: 1, email: "test@example.com" });
|
|
304
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
305
|
+
expect(effectFn).toHaveBeenCalledWith("test@example.com");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("should run cleanup when transitioning from non-null to null", async () => {
|
|
309
|
+
const cleanupFn = vi.fn();
|
|
310
|
+
const effectFn = vi.fn();
|
|
311
|
+
const id$ = atom<string | null>("initial");
|
|
312
|
+
|
|
313
|
+
effect(({ ready, onCleanup }) => {
|
|
314
|
+
const id = ready(id$);
|
|
315
|
+
effectFn(id);
|
|
316
|
+
onCleanup(cleanupFn);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
320
|
+
expect(effectFn).toHaveBeenCalledWith("initial");
|
|
321
|
+
expect(cleanupFn).not.toHaveBeenCalled();
|
|
322
|
+
|
|
323
|
+
// Set to null - should trigger cleanup from previous run
|
|
324
|
+
id$.set(null);
|
|
325
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
326
|
+
expect(cleanupFn).toHaveBeenCalledTimes(1);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("should work with multiple ready() calls", async () => {
|
|
330
|
+
const effectFn = vi.fn();
|
|
331
|
+
const firstName$ = atom<string | null>(null);
|
|
332
|
+
const lastName$ = atom<string | null>(null);
|
|
333
|
+
|
|
334
|
+
effect(({ ready }) => {
|
|
335
|
+
const first = ready(firstName$);
|
|
336
|
+
const last = ready(lastName$);
|
|
337
|
+
effectFn(`${first} ${last}`);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
341
|
+
expect(effectFn).not.toHaveBeenCalled();
|
|
342
|
+
|
|
343
|
+
// Set only firstName - still suspended
|
|
344
|
+
firstName$.set("John");
|
|
345
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
346
|
+
expect(effectFn).not.toHaveBeenCalled();
|
|
347
|
+
|
|
348
|
+
// Set lastName - effect should run
|
|
349
|
+
lastName$.set("Doe");
|
|
350
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
351
|
+
expect(effectFn).toHaveBeenCalledWith("John Doe");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("should allow mixing ready() with read()", async () => {
|
|
355
|
+
const effectFn = vi.fn();
|
|
356
|
+
const requiredId$ = atom<string | null>(null);
|
|
357
|
+
const optionalLabel$ = atom("default");
|
|
358
|
+
|
|
359
|
+
effect(({ ready, read }) => {
|
|
360
|
+
const id = ready(requiredId$);
|
|
361
|
+
const label = read(optionalLabel$);
|
|
362
|
+
effectFn({ id, label });
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
366
|
+
expect(effectFn).not.toHaveBeenCalled();
|
|
367
|
+
|
|
368
|
+
// Set required value
|
|
369
|
+
requiredId$.set("123");
|
|
370
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
371
|
+
expect(effectFn).toHaveBeenCalledWith({ id: "123", label: "default" });
|
|
372
|
+
|
|
373
|
+
// Change optional value
|
|
374
|
+
effectFn.mockClear();
|
|
375
|
+
optionalLabel$.set("custom");
|
|
376
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
377
|
+
expect(effectFn).toHaveBeenCalledWith({ id: "123", label: "custom" });
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("should handle real-world: sync to localStorage only when user is logged in", async () => {
|
|
381
|
+
const mockStorage: Record<string, string> = {};
|
|
382
|
+
const currentUser$ = atom<{ id: string } | null>(null);
|
|
383
|
+
const preferences$ = atom({ theme: "dark" });
|
|
384
|
+
|
|
385
|
+
effect(({ ready, read, onCleanup }) => {
|
|
386
|
+
const user = ready(currentUser$);
|
|
387
|
+
const prefs = read(preferences$);
|
|
388
|
+
|
|
389
|
+
// Sync preferences to localStorage for logged-in user
|
|
390
|
+
mockStorage[`prefs:${user.id}`] = JSON.stringify(prefs);
|
|
391
|
+
|
|
392
|
+
onCleanup(() => {
|
|
393
|
+
delete mockStorage[`prefs:${user.id}`];
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
398
|
+
// No user logged in - nothing in storage
|
|
399
|
+
expect(Object.keys(mockStorage)).toHaveLength(0);
|
|
400
|
+
|
|
401
|
+
// User logs in
|
|
402
|
+
currentUser$.set({ id: "u1" });
|
|
403
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
404
|
+
expect(mockStorage["prefs:u1"]).toBe('{"theme":"dark"}');
|
|
405
|
+
|
|
406
|
+
// Preferences change
|
|
407
|
+
preferences$.set({ theme: "light" });
|
|
408
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
409
|
+
expect(mockStorage["prefs:u1"]).toBe('{"theme":"light"}');
|
|
410
|
+
|
|
411
|
+
// User logs out - cleanup runs
|
|
412
|
+
currentUser$.set(null);
|
|
413
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
414
|
+
expect(mockStorage["prefs:u1"]).toBeUndefined();
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
describe("Effect return type", () => {
|
|
419
|
+
it("should return Effect object with dispose function", () => {
|
|
420
|
+
const e = effect(() => {});
|
|
421
|
+
|
|
422
|
+
expect(e).toHaveProperty("dispose");
|
|
423
|
+
expect(typeof e.dispose).toBe("function");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("should return Effect object with meta when provided", () => {
|
|
427
|
+
const e = effect(() => {}, {
|
|
428
|
+
meta: { key: "myEffect" },
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
expect(e.meta).toEqual({ key: "myEffect" });
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("should return Effect object with undefined meta when not provided", () => {
|
|
435
|
+
const e = effect(() => {});
|
|
436
|
+
|
|
437
|
+
expect(e.meta).toBeUndefined();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("should return Effect object that satisfies Effect interface", () => {
|
|
441
|
+
const e: Effect = effect(() => {}, {
|
|
442
|
+
meta: { key: "typedEffect" },
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// Type check - this should compile
|
|
446
|
+
const dispose: VoidFunction = e.dispose;
|
|
447
|
+
expect(dispose).toBeDefined();
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
describe("onCreateHook", () => {
|
|
452
|
+
beforeEach(() => {
|
|
453
|
+
onCreateHook.reset();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
afterEach(() => {
|
|
457
|
+
onCreateHook.reset();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("should call onCreateHook when effect is created", () => {
|
|
461
|
+
const hookFn = vi.fn();
|
|
462
|
+
onCreateHook.override(() => hookFn);
|
|
463
|
+
|
|
464
|
+
const e = effect(() => {}, { meta: { key: "testEffect" } });
|
|
465
|
+
|
|
466
|
+
// effect() internally creates a derived atom, so hook is called twice:
|
|
467
|
+
// 1. for the internal derived atom
|
|
468
|
+
// 2. for the effect itself
|
|
469
|
+
const effectCall = hookFn.mock.calls.find(
|
|
470
|
+
(call) => call[0].type === "effect"
|
|
471
|
+
);
|
|
472
|
+
expect(effectCall).toBeDefined();
|
|
473
|
+
expect(effectCall![0]).toEqual({
|
|
474
|
+
type: "effect",
|
|
475
|
+
key: "testEffect",
|
|
476
|
+
meta: { key: "testEffect" },
|
|
477
|
+
instance: e,
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("should call onCreateHook with undefined key when not provided", () => {
|
|
482
|
+
const hookFn = vi.fn();
|
|
483
|
+
onCreateHook.override(() => hookFn);
|
|
484
|
+
|
|
485
|
+
const e = effect(() => {});
|
|
486
|
+
|
|
487
|
+
const effectCall = hookFn.mock.calls.find(
|
|
488
|
+
(call) => call[0].type === "effect"
|
|
489
|
+
);
|
|
490
|
+
expect(effectCall).toBeDefined();
|
|
491
|
+
expect(effectCall![0]).toEqual({
|
|
492
|
+
type: "effect",
|
|
493
|
+
key: undefined,
|
|
494
|
+
meta: undefined,
|
|
495
|
+
instance: e,
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("should not throw when onCreateHook is undefined", () => {
|
|
500
|
+
onCreateHook.reset();
|
|
501
|
+
|
|
502
|
+
expect(() => effect(() => {})).not.toThrow();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("should call onCreateHook with effect instance that has working dispose", async () => {
|
|
506
|
+
const hookFn = vi.fn();
|
|
507
|
+
onCreateHook.override(() => hookFn);
|
|
508
|
+
|
|
509
|
+
const cleanupFn = vi.fn();
|
|
510
|
+
const count$ = atom(0);
|
|
511
|
+
|
|
512
|
+
effect(({ read, onCleanup }) => {
|
|
513
|
+
read(count$);
|
|
514
|
+
onCleanup(cleanupFn);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
518
|
+
|
|
519
|
+
// Get the effect from the hook call (filter out the internal derived atom call)
|
|
520
|
+
const effectCall = hookFn.mock.calls.find(
|
|
521
|
+
(call) => call[0].type === "effect"
|
|
522
|
+
);
|
|
523
|
+
expect(effectCall).toBeDefined();
|
|
524
|
+
const capturedEffect = effectCall![0].instance as Effect;
|
|
525
|
+
|
|
526
|
+
// Dispose should work
|
|
527
|
+
capturedEffect.dispose();
|
|
528
|
+
expect(cleanupFn).toHaveBeenCalledTimes(1);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("should pass correct type discriminator for effects", () => {
|
|
532
|
+
const hookFn = vi.fn();
|
|
533
|
+
onCreateHook.override(() => hookFn);
|
|
534
|
+
|
|
535
|
+
effect(() => {});
|
|
536
|
+
|
|
537
|
+
// Find the effect call (not the internal derived call)
|
|
538
|
+
const effectCall = hookFn.mock.calls.find(
|
|
539
|
+
(call) => call[0].type === "effect"
|
|
540
|
+
);
|
|
541
|
+
expect(effectCall).toBeDefined();
|
|
542
|
+
expect(effectCall![0].type).toBe("effect");
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("should allow tracking effects in devtools-like scenario", () => {
|
|
546
|
+
const effects = new Map<string, Effect>();
|
|
547
|
+
onCreateHook.override(() => (info) => {
|
|
548
|
+
if (info.type === "effect" && info.key) {
|
|
549
|
+
effects.set(info.key, info.instance);
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
const e1 = effect(() => {}, { meta: { key: "effect1" } });
|
|
554
|
+
const e2 = effect(() => {}, { meta: { key: "effect2" } });
|
|
555
|
+
effect(() => {}); // Anonymous - should not be tracked
|
|
556
|
+
|
|
557
|
+
expect(effects.size).toBe(2);
|
|
558
|
+
expect(effects.get("effect1")).toBe(e1);
|
|
559
|
+
expect(effects.get("effect2")).toBe(e2);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("should support disposing all tracked effects", async () => {
|
|
563
|
+
const effects: Effect[] = [];
|
|
564
|
+
onCreateHook.override(() => (info) => {
|
|
565
|
+
if (info.type === "effect") {
|
|
566
|
+
effects.push(info.instance);
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
const cleanupFns = [vi.fn(), vi.fn(), vi.fn()];
|
|
571
|
+
const count$ = atom(0);
|
|
572
|
+
|
|
573
|
+
cleanupFns.forEach((cleanup) => {
|
|
574
|
+
effect(({ read, onCleanup }) => {
|
|
575
|
+
read(count$);
|
|
576
|
+
onCleanup(cleanup);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
581
|
+
|
|
582
|
+
// Dispose all tracked effects
|
|
583
|
+
effects.forEach((e) => e.dispose());
|
|
584
|
+
|
|
585
|
+
cleanupFns.forEach((cleanup) => {
|
|
586
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
describe("onError callback", () => {
|
|
592
|
+
it("should call onError when effect throws synchronously", async () => {
|
|
593
|
+
const onError = vi.fn();
|
|
594
|
+
const source$ = atom(0);
|
|
595
|
+
|
|
596
|
+
effect(
|
|
597
|
+
({ read }) => {
|
|
598
|
+
const val = read(source$);
|
|
599
|
+
if (val > 0) {
|
|
600
|
+
throw new Error("Effect error");
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
{ onError }
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
607
|
+
expect(onError).not.toHaveBeenCalled();
|
|
608
|
+
|
|
609
|
+
// Trigger error
|
|
610
|
+
source$.set(5);
|
|
611
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
612
|
+
|
|
613
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
614
|
+
expect((onError.mock.calls[0][0] as Error).message).toBe("Effect error");
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("should call onError when async atom dependency rejects", async () => {
|
|
618
|
+
const onError = vi.fn();
|
|
619
|
+
|
|
620
|
+
// Create an atom with a rejecting Promise
|
|
621
|
+
const asyncSource$ = atom(Promise.reject(new Error("Async error")));
|
|
622
|
+
|
|
623
|
+
effect(
|
|
624
|
+
({ read }) => {
|
|
625
|
+
read(asyncSource$);
|
|
626
|
+
},
|
|
627
|
+
{ onError }
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
631
|
+
|
|
632
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
633
|
+
expect((onError.mock.calls[0][0] as Error).message).toBe("Async error");
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it("should call onError on each recomputation that throws", async () => {
|
|
637
|
+
const onError = vi.fn();
|
|
638
|
+
const source$ = atom(0);
|
|
639
|
+
|
|
640
|
+
effect(
|
|
641
|
+
({ read }) => {
|
|
642
|
+
const val = read(source$);
|
|
643
|
+
if (val > 0) {
|
|
644
|
+
throw new Error(`Error for ${val}`);
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
{ onError }
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
651
|
+
expect(onError).not.toHaveBeenCalled();
|
|
652
|
+
|
|
653
|
+
// First error
|
|
654
|
+
source$.set(1);
|
|
655
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
656
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
657
|
+
|
|
658
|
+
// Second error
|
|
659
|
+
source$.set(2);
|
|
660
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
661
|
+
expect(onError).toHaveBeenCalledTimes(2);
|
|
662
|
+
expect((onError.mock.calls[1][0] as Error).message).toBe("Error for 2");
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it("should not call onError when effect succeeds", async () => {
|
|
666
|
+
const onError = vi.fn();
|
|
667
|
+
const effectFn = vi.fn();
|
|
668
|
+
const source$ = atom(5);
|
|
669
|
+
|
|
670
|
+
effect(
|
|
671
|
+
({ read }) => {
|
|
672
|
+
effectFn(read(source$));
|
|
673
|
+
},
|
|
674
|
+
{ onError }
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
678
|
+
source$.set(10);
|
|
679
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
680
|
+
source$.set(15);
|
|
681
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
682
|
+
|
|
683
|
+
expect(effectFn).toHaveBeenCalledTimes(3);
|
|
684
|
+
expect(onError).not.toHaveBeenCalled();
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it("should not call onError for Promise throws (Suspense)", async () => {
|
|
688
|
+
const onError = vi.fn();
|
|
689
|
+
const effectFn = vi.fn();
|
|
690
|
+
let resolvePromise: (value: number) => void;
|
|
691
|
+
const asyncSource$ = atom(
|
|
692
|
+
new Promise<number>((resolve) => {
|
|
693
|
+
resolvePromise = resolve;
|
|
694
|
+
})
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
effect(
|
|
698
|
+
({ read }) => {
|
|
699
|
+
effectFn(read(asyncSource$));
|
|
700
|
+
},
|
|
701
|
+
{ onError }
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
// Still loading - onError should NOT be called
|
|
705
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
706
|
+
expect(onError).not.toHaveBeenCalled();
|
|
707
|
+
expect(effectFn).not.toHaveBeenCalled();
|
|
708
|
+
|
|
709
|
+
// Resolve successfully
|
|
710
|
+
resolvePromise!(5);
|
|
711
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
712
|
+
expect(effectFn).toHaveBeenCalledWith(5);
|
|
713
|
+
expect(onError).not.toHaveBeenCalled();
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it("should work without onError callback", async () => {
|
|
717
|
+
const source$ = atom(0);
|
|
718
|
+
|
|
719
|
+
// Should not throw even without onError
|
|
720
|
+
effect(({ read }) => {
|
|
721
|
+
const val = read(source$);
|
|
722
|
+
if (val > 0) {
|
|
723
|
+
throw new Error("Error");
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
728
|
+
source$.set(5);
|
|
729
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
730
|
+
// No crash - test passes
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("should allow combining onError with safe() for different error handling strategies", async () => {
|
|
734
|
+
const onError = vi.fn();
|
|
735
|
+
const handledErrors: unknown[] = [];
|
|
736
|
+
const source$ = atom(0);
|
|
737
|
+
|
|
738
|
+
effect(
|
|
739
|
+
({ read, safe }) => {
|
|
740
|
+
const val = read(source$);
|
|
741
|
+
|
|
742
|
+
// Use safe() for recoverable errors
|
|
743
|
+
const [err] = safe(() => {
|
|
744
|
+
if (val === 1) {
|
|
745
|
+
throw new Error("Handled error");
|
|
746
|
+
}
|
|
747
|
+
return val;
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
if (err) {
|
|
751
|
+
handledErrors.push(err);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Unhandled errors go to onError
|
|
756
|
+
if (val === 2) {
|
|
757
|
+
throw new Error("Unhandled error");
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
{ onError }
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
764
|
+
|
|
765
|
+
// Handled error via safe()
|
|
766
|
+
source$.set(1);
|
|
767
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
768
|
+
expect(handledErrors.length).toBe(1);
|
|
769
|
+
expect(onError).not.toHaveBeenCalled();
|
|
770
|
+
|
|
771
|
+
// Unhandled error goes to onError
|
|
772
|
+
source$.set(2);
|
|
773
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
774
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
775
|
+
expect((onError.mock.calls[0][0] as Error).message).toBe(
|
|
776
|
+
"Unhandled error"
|
|
777
|
+
);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it("should pass onError to internal derived atom", async () => {
|
|
781
|
+
// This test verifies the implementation detail that effect passes
|
|
782
|
+
// onError to the internal derived atom
|
|
783
|
+
const onError = vi.fn();
|
|
784
|
+
const source$ = atom(0);
|
|
785
|
+
|
|
786
|
+
effect(
|
|
787
|
+
({ read }) => {
|
|
788
|
+
const val = read(source$);
|
|
789
|
+
if (val > 0) throw new Error("Test");
|
|
790
|
+
},
|
|
791
|
+
{ onError }
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
795
|
+
source$.set(1);
|
|
796
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
797
|
+
|
|
798
|
+
// onError was called, proving it was passed to derived
|
|
799
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
800
|
+
});
|
|
801
|
+
});
|
|
196
802
|
});
|