atomirx 0.0.2 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +868 -161
  2. package/coverage/src/core/onCreateHook.ts.html +72 -70
  3. package/dist/core/atom.d.ts +83 -6
  4. package/dist/core/batch.d.ts +3 -3
  5. package/dist/core/derived.d.ts +69 -22
  6. package/dist/core/effect.d.ts +52 -52
  7. package/dist/core/getAtomState.d.ts +29 -0
  8. package/dist/core/hook.d.ts +1 -1
  9. package/dist/core/onCreateHook.d.ts +37 -23
  10. package/dist/core/onErrorHook.d.ts +49 -0
  11. package/dist/core/promiseCache.d.ts +23 -32
  12. package/dist/core/select.d.ts +208 -29
  13. package/dist/core/types.d.ts +107 -22
  14. package/dist/core/withReady.d.ts +115 -0
  15. package/dist/core/withReady.test.d.ts +1 -0
  16. package/dist/index-CBVj1kSj.js +1350 -0
  17. package/dist/index-Cxk9v0um.cjs +1 -0
  18. package/dist/index.cjs +1 -1
  19. package/dist/index.d.ts +12 -8
  20. package/dist/index.js +18 -15
  21. package/dist/react/index.cjs +10 -10
  22. package/dist/react/index.d.ts +2 -1
  23. package/dist/react/index.js +422 -377
  24. package/dist/react/rx.d.ts +114 -25
  25. package/dist/react/useAction.d.ts +5 -4
  26. package/dist/react/{useValue.d.ts → useSelector.d.ts} +56 -25
  27. package/dist/react/useSelector.test.d.ts +1 -0
  28. package/package.json +1 -1
  29. package/src/core/atom.test.ts +307 -43
  30. package/src/core/atom.ts +144 -22
  31. package/src/core/batch.test.ts +10 -10
  32. package/src/core/batch.ts +3 -3
  33. package/src/core/define.test.ts +12 -11
  34. package/src/core/define.ts +1 -1
  35. package/src/core/derived.test.ts +906 -72
  36. package/src/core/derived.ts +192 -81
  37. package/src/core/effect.test.ts +651 -45
  38. package/src/core/effect.ts +102 -98
  39. package/src/core/getAtomState.ts +69 -0
  40. package/src/core/hook.test.ts +5 -5
  41. package/src/core/hook.ts +1 -1
  42. package/src/core/onCreateHook.ts +38 -23
  43. package/src/core/onErrorHook.test.ts +350 -0
  44. package/src/core/onErrorHook.ts +52 -0
  45. package/src/core/promiseCache.test.ts +5 -3
  46. package/src/core/promiseCache.ts +76 -71
  47. package/src/core/select.ts +405 -130
  48. package/src/core/selector.test.ts +574 -32
  49. package/src/core/types.ts +107 -29
  50. package/src/core/withReady.test.ts +534 -0
  51. package/src/core/withReady.ts +191 -0
  52. package/src/core/withUse.ts +1 -1
  53. package/src/index.test.ts +4 -4
  54. package/src/index.ts +21 -7
  55. package/src/react/index.ts +2 -1
  56. package/src/react/rx.test.tsx +173 -18
  57. package/src/react/rx.tsx +274 -43
  58. package/src/react/useAction.test.ts +12 -14
  59. package/src/react/useAction.ts +11 -9
  60. package/src/react/{useValue.test.ts → useSelector.test.ts} +16 -16
  61. package/src/react/{useValue.ts → useSelector.ts} +64 -33
  62. package/v2.md +44 -44
  63. package/dist/index-2ok7ilik.js +0 -1217
  64. package/dist/index-B_5SFzfl.cjs +0 -1
  65. /package/dist/{react/useValue.test.d.ts → core/onErrorHook.test.d.ts} +0 -0
@@ -7,56 +7,56 @@ describe("derived", () => {
7
7
  describe("basic functionality", () => {
8
8
  it("should create a derived atom from a source atom", async () => {
9
9
  const count$ = atom(5);
10
- const doubled$ = derived(({ get }) => get(count$) * 2);
10
+ const doubled$ = derived(({ read }) => read(count$) * 2);
11
11
 
12
- expect(await doubled$.value).toBe(10);
12
+ expect(await doubled$.get()).toBe(10);
13
13
  });
14
14
 
15
15
  it("should have SYMBOL_ATOM and SYMBOL_DERIVED markers", () => {
16
16
  const count$ = atom(0);
17
- const doubled$ = derived(({ get }) => get(count$) * 2);
17
+ const doubled$ = derived(({ read }) => read(count$) * 2);
18
18
 
19
19
  expect(doubled$[SYMBOL_ATOM]).toBe(true);
20
20
  expect(doubled$[SYMBOL_DERIVED]).toBe(true);
21
21
  });
22
22
 
23
- it("should always return a Promise from .value", () => {
23
+ it("should always return a Promise from .get()", () => {
24
24
  const count$ = atom(5);
25
- const doubled$ = derived(({ get }) => get(count$) * 2);
25
+ const doubled$ = derived(({ read }) => read(count$) * 2);
26
26
 
27
- expect(doubled$.value).toBeInstanceOf(Promise);
27
+ expect(doubled$.get()).toBeInstanceOf(Promise);
28
28
  });
29
29
 
30
30
  it("should update when source atom changes", async () => {
31
31
  const count$ = atom(5);
32
- const doubled$ = derived(({ get }) => get(count$) * 2);
32
+ const doubled$ = derived(({ read }) => read(count$) * 2);
33
33
 
34
- expect(await doubled$.value).toBe(10);
34
+ expect(await doubled$.get()).toBe(10);
35
35
  count$.set(10);
36
- expect(await doubled$.value).toBe(20);
36
+ expect(await doubled$.get()).toBe(20);
37
37
  });
38
38
 
39
39
  it("should derive from multiple atoms", async () => {
40
40
  const a$ = atom(2);
41
41
  const b$ = atom(3);
42
- const sum$ = derived(({ get }) => get(a$) + get(b$));
42
+ const sum$ = derived(({ read }) => read(a$) + read(b$));
43
43
 
44
- expect(await sum$.value).toBe(5);
44
+ expect(await sum$.get()).toBe(5);
45
45
  a$.set(10);
46
- expect(await sum$.value).toBe(13);
46
+ expect(await sum$.get()).toBe(13);
47
47
  b$.set(7);
48
- expect(await sum$.value).toBe(17);
48
+ expect(await sum$.get()).toBe(17);
49
49
  });
50
50
  });
51
51
 
52
52
  describe("staleValue", () => {
53
53
  it("should return undefined initially without fallback", async () => {
54
54
  const count$ = atom(5);
55
- const doubled$ = derived(({ get }) => get(count$) * 2);
55
+ const doubled$ = derived(({ read }) => read(count$) * 2);
56
56
 
57
57
  // Before resolution, staleValue is undefined (no fallback)
58
58
  // After resolution, it becomes the resolved value
59
- await doubled$.value;
59
+ await doubled$.get();
60
60
  expect(doubled$.staleValue).toBe(10);
61
61
  });
62
62
 
@@ -64,7 +64,7 @@ describe("derived", () => {
64
64
  // For sync atoms, computation is immediate so staleValue is already resolved
65
65
  // Test with async dependency to verify fallback behavior
66
66
  const asyncValue$ = atom(new Promise<number>(() => {})); // Never resolves
67
- const derived$ = derived(({ get }) => get(asyncValue$) * 2, {
67
+ const derived$ = derived(({ read }) => read(asyncValue$) * 2, {
68
68
  fallback: 0,
69
69
  });
70
70
 
@@ -75,23 +75,23 @@ describe("derived", () => {
75
75
 
76
76
  it("should return resolved value for sync computation", async () => {
77
77
  const count$ = atom(5);
78
- const doubled$ = derived(({ get }) => get(count$) * 2, { fallback: 0 });
78
+ const doubled$ = derived(({ read }) => read(count$) * 2, { fallback: 0 });
79
79
 
80
80
  // Sync computation resolves immediately
81
- await doubled$.value;
81
+ await doubled$.get();
82
82
  expect(doubled$.staleValue).toBe(10);
83
83
  });
84
84
 
85
85
  it("should update staleValue after resolution", async () => {
86
86
  const count$ = atom(5);
87
- const doubled$ = derived(({ get }) => get(count$) * 2, { fallback: 0 });
87
+ const doubled$ = derived(({ read }) => read(count$) * 2, { fallback: 0 });
88
88
 
89
- await doubled$.value;
89
+ await doubled$.get();
90
90
  expect(doubled$.staleValue).toBe(10);
91
91
 
92
92
  count$.set(20);
93
93
  // After recomputation
94
- await doubled$.value;
94
+ await doubled$.get();
95
95
  expect(doubled$.staleValue).toBe(40);
96
96
  });
97
97
  });
@@ -99,7 +99,7 @@ describe("derived", () => {
99
99
  describe("state", () => {
100
100
  it("should return loading status during loading", async () => {
101
101
  const asyncValue$ = atom(new Promise<number>(() => {})); // Never resolves
102
- const doubled$ = derived(({ get }) => get(asyncValue$) * 2);
102
+ const doubled$ = derived(({ read }) => read(asyncValue$) * 2);
103
103
 
104
104
  const state = doubled$.state();
105
105
  expect(state.status).toBe("loading");
@@ -107,7 +107,7 @@ describe("derived", () => {
107
107
 
108
108
  it("should return loading status with fallback during loading", async () => {
109
109
  const asyncValue$ = atom(new Promise<number>(() => {})); // Never resolves
110
- const doubled$ = derived(({ get }) => get(asyncValue$) * 2, {
110
+ const doubled$ = derived(({ read }) => read(asyncValue$) * 2, {
111
111
  fallback: 0,
112
112
  });
113
113
 
@@ -119,10 +119,10 @@ describe("derived", () => {
119
119
 
120
120
  it("should return ready status after resolved", async () => {
121
121
  const count$ = atom(5);
122
- const doubled$ = derived(({ get }) => get(count$) * 2, { fallback: 0 });
122
+ const doubled$ = derived(({ read }) => read(count$) * 2, { fallback: 0 });
123
123
 
124
124
  // Sync computation resolves immediately
125
- await doubled$.value;
125
+ await doubled$.get();
126
126
 
127
127
  const state = doubled$.state();
128
128
  expect(state.status).toBe("ready");
@@ -134,16 +134,16 @@ describe("derived", () => {
134
134
  it("should return error status on error", async () => {
135
135
  const error = new Error("Test error");
136
136
  const count$ = atom(5);
137
- const willThrow$ = derived(({ get }) => {
138
- if (get(count$) > 3) {
137
+ const willThrow$ = derived(({ read }) => {
138
+ if (read(count$) > 3) {
139
139
  throw error;
140
140
  }
141
- return get(count$);
141
+ return read(count$);
142
142
  });
143
143
 
144
144
  // Wait for computation to complete
145
145
  try {
146
- await willThrow$.value;
146
+ await willThrow$.get();
147
147
  } catch {
148
148
  // Expected to throw
149
149
  }
@@ -162,7 +162,7 @@ describe("derived", () => {
162
162
  resolvePromise = resolve;
163
163
  })
164
164
  );
165
- const doubled$ = derived(({ get }) => get(asyncValue$) * 2, {
165
+ const doubled$ = derived(({ read }) => read(asyncValue$) * 2, {
166
166
  fallback: 0,
167
167
  });
168
168
 
@@ -172,7 +172,7 @@ describe("derived", () => {
172
172
 
173
173
  // Resolve the promise
174
174
  resolvePromise!(5);
175
- await doubled$.value;
175
+ await doubled$.get();
176
176
 
177
177
  // Now ready
178
178
  const state = doubled$.state();
@@ -188,17 +188,17 @@ describe("derived", () => {
188
188
  it("should re-run computation on refresh", async () => {
189
189
  let callCount = 0;
190
190
  const count$ = atom(5);
191
- const doubled$ = derived(({ get }) => {
191
+ const doubled$ = derived(({ read }) => {
192
192
  callCount++;
193
- return get(count$) * 2;
193
+ return read(count$) * 2;
194
194
  });
195
195
 
196
- await doubled$.value;
196
+ await doubled$.get();
197
197
  expect(callCount).toBeGreaterThanOrEqual(1);
198
198
 
199
199
  const countBefore = callCount;
200
200
  doubled$.refresh();
201
- await doubled$.value;
201
+ await doubled$.get();
202
202
  expect(callCount).toBeGreaterThan(countBefore);
203
203
  });
204
204
  });
@@ -206,29 +206,29 @@ describe("derived", () => {
206
206
  describe("subscriptions", () => {
207
207
  it("should notify subscribers when derived value changes", async () => {
208
208
  const count$ = atom(5);
209
- const doubled$ = derived(({ get }) => get(count$) * 2);
209
+ const doubled$ = derived(({ read }) => read(count$) * 2);
210
210
  const listener = vi.fn();
211
211
 
212
- await doubled$.value; // Initialize
212
+ await doubled$.get(); // Initialize
213
213
  doubled$.on(listener);
214
214
 
215
215
  count$.set(10);
216
- await doubled$.value; // Wait for recomputation
216
+ await doubled$.get(); // Wait for recomputation
217
217
 
218
218
  expect(listener).toHaveBeenCalled();
219
219
  });
220
220
 
221
221
  it("should not notify if derived value is the same", async () => {
222
222
  const count$ = atom(5);
223
- const clamped$ = derived(({ get }) => Math.min(get(count$), 10));
223
+ const clamped$ = derived(({ read }) => Math.min(read(count$), 10));
224
224
  const listener = vi.fn();
225
225
 
226
- await clamped$.value;
226
+ await clamped$.get();
227
227
  clamped$.on(listener);
228
228
 
229
229
  // Value is already clamped to 10
230
230
  count$.set(15); // Still clamps to 10
231
- await clamped$.value;
231
+ await clamped$.get();
232
232
 
233
233
  // Should still notify because we can't detect same output without full tracking
234
234
  // This depends on implementation - adjust expectation as needed
@@ -236,20 +236,20 @@ describe("derived", () => {
236
236
 
237
237
  it("should allow unsubscribing", async () => {
238
238
  const count$ = atom(5);
239
- const doubled$ = derived(({ get }) => get(count$) * 2);
239
+ const doubled$ = derived(({ read }) => read(count$) * 2);
240
240
  const listener = vi.fn();
241
241
 
242
- await doubled$.value;
242
+ await doubled$.get();
243
243
  const unsub = doubled$.on(listener);
244
244
 
245
245
  count$.set(10);
246
- await doubled$.value;
246
+ await doubled$.get();
247
247
  const callCount = listener.mock.calls.length;
248
248
 
249
249
  unsub();
250
250
 
251
251
  count$.set(20);
252
- await doubled$.value;
252
+ await doubled$.get();
253
253
 
254
254
  // Should not receive more calls after unsubscribe
255
255
  expect(listener.mock.calls.length).toBe(callCount);
@@ -262,38 +262,38 @@ describe("derived", () => {
262
262
  const summary$ = atom("Brief");
263
263
  const details$ = atom("Detailed");
264
264
 
265
- const content$ = derived(({ get }) =>
266
- get(showDetails$) ? get(details$) : get(summary$)
265
+ const content$ = derived(({ read }) =>
266
+ read(showDetails$) ? read(details$) : read(summary$)
267
267
  );
268
268
 
269
269
  const listener = vi.fn();
270
- await content$.value;
270
+ await content$.get();
271
271
  content$.on(listener);
272
272
 
273
273
  // Initially showing summary, so details changes shouldn't trigger
274
274
  // (This depends on implementation - conditional deps may or may not
275
275
  // unsubscribe from unaccessed atoms)
276
276
 
277
- expect(await content$.value).toBe("Brief");
277
+ expect(await content$.get()).toBe("Brief");
278
278
 
279
279
  showDetails$.set(true);
280
- expect(await content$.value).toBe("Detailed");
280
+ expect(await content$.get()).toBe("Detailed");
281
281
  });
282
282
  });
283
283
 
284
284
  describe("async dependencies", () => {
285
285
  it("should handle atoms storing Promises", async () => {
286
286
  const asyncValue$ = atom(Promise.resolve(42));
287
- const doubled$ = derived(({ get }) => {
288
- const value = get(asyncValue$);
289
- // At this point, get() will throw the Promise if pending
287
+ const doubled$ = derived(({ read }) => {
288
+ const value = read(asyncValue$);
289
+ // At this point, read() will throw the Promise if pending
290
290
  // which is handled by derived's internal retry mechanism
291
291
  return value;
292
292
  });
293
293
 
294
294
  // The derived computation handles the async dependency
295
295
  // This test verifies the basic wiring works
296
- await doubled$.value;
296
+ await doubled$.get();
297
297
  // Result depends on how promiseCache tracks the Promise
298
298
  expect(true).toBe(true); // Test passes if no error thrown
299
299
  });
@@ -302,17 +302,17 @@ describe("derived", () => {
302
302
  describe("error handling", () => {
303
303
  it("should propagate errors from computation", async () => {
304
304
  const count$ = atom(5);
305
- const willThrow$ = derived(({ get }) => {
306
- if (get(count$) > 10) {
305
+ const willThrow$ = derived(({ read }) => {
306
+ if (read(count$) > 10) {
307
307
  throw new Error("Value too high");
308
308
  }
309
- return get(count$);
309
+ return read(count$);
310
310
  });
311
311
 
312
- expect(await willThrow$.value).toBe(5);
312
+ expect(await willThrow$.get()).toBe(5);
313
313
 
314
314
  count$.set(15);
315
- await expect(willThrow$.value).rejects.toThrow("Value too high");
315
+ await expect(willThrow$.get()).rejects.toThrow("Value too high");
316
316
  });
317
317
  });
318
318
 
@@ -323,38 +323,38 @@ describe("derived", () => {
323
323
  const c$ = atom(3);
324
324
 
325
325
  const sum$ = derived(({ all }) => {
326
- const [a, b, c] = all(a$, b$, c$);
326
+ const [a, b, c] = all([a$, b$, c$]);
327
327
  return a + b + c;
328
328
  });
329
329
 
330
- expect(await sum$.value).toBe(6);
330
+ expect(await sum$.get()).toBe(6);
331
331
  });
332
332
 
333
- it("should support get() chaining", async () => {
333
+ it("should support read() chaining", async () => {
334
334
  const a$ = atom(2);
335
335
  const b$ = atom(3);
336
336
 
337
- const result$ = derived(({ get }) => {
338
- const a = get(a$);
339
- const b = get(b$);
337
+ const result$ = derived(({ read }) => {
338
+ const a = read(a$);
339
+ const b = read(b$);
340
340
  return a * b;
341
341
  });
342
342
 
343
- expect(await result$.value).toBe(6);
343
+ expect(await result$.get()).toBe(6);
344
344
  });
345
345
  });
346
346
 
347
347
  describe("equality options", () => {
348
348
  it("should use strict equality by default", async () => {
349
349
  const source$ = atom({ a: 1 });
350
- const derived$ = derived(({ get }) => ({ ...get(source$) }));
350
+ const derived$ = derived(({ read }) => ({ ...read(source$) }));
351
351
  const listener = vi.fn();
352
352
 
353
- await derived$.value;
353
+ await derived$.get();
354
354
  derived$.on(listener);
355
355
 
356
356
  source$.set({ a: 1 }); // Same content, different reference
357
- await derived$.value;
357
+ await derived$.get();
358
358
 
359
359
  // With strict equality on derived output, listener should be called
360
360
  // because we return a new object each time
@@ -363,19 +363,853 @@ describe("derived", () => {
363
363
 
364
364
  it("should support shallow equality option", async () => {
365
365
  const source$ = atom({ a: 1 });
366
- const derived$ = derived(({ get }) => ({ ...get(source$) }), {
366
+ const derived$ = derived(({ read }) => ({ ...read(source$) }), {
367
367
  equals: "shallow",
368
368
  });
369
369
  const listener = vi.fn();
370
370
 
371
- await derived$.value;
371
+ await derived$.get();
372
372
  derived$.on(listener);
373
373
 
374
374
  source$.set({ a: 1 }); // Same content
375
- await derived$.value;
375
+ await derived$.get();
376
376
 
377
377
  // With shallow equality, same content should not notify
378
378
  // (depends on whether source triggers derived recomputation)
379
379
  });
380
380
  });
381
+
382
+ describe("bug fixes", () => {
383
+ describe("notify on loading state (Bug #1)", () => {
384
+ it("should notify downstream derived atoms when entering loading state", async () => {
385
+ // Bug: When a derived atom's dependency starts loading,
386
+ // it didn't notify subscribers, causing downstream atoms
387
+ // and useSelector to not suspend properly
388
+ let resolveFirst: (value: number) => void;
389
+ const firstPromise = new Promise<number>((r) => {
390
+ resolveFirst = r;
391
+ });
392
+ const base$ = atom(firstPromise);
393
+
394
+ // Create a chain: base$ -> derived1$ -> derived2$
395
+ const derived1$ = derived(({ read }) => read(base$) * 2);
396
+ const derived2$ = derived(({ read }) => read(derived1$) + 1);
397
+
398
+ const listener = vi.fn();
399
+ derived2$.on(listener);
400
+
401
+ // Initially loading
402
+ expect(derived2$.state().status).toBe("loading");
403
+
404
+ // Resolve and trigger recompute
405
+ resolveFirst!(5);
406
+ await derived2$.get();
407
+
408
+ expect(derived2$.state().status).toBe("ready");
409
+ // Listener should have been called when state changed
410
+ expect(listener).toHaveBeenCalled();
411
+ });
412
+
413
+ it("should propagate loading state through derived chain", async () => {
414
+ let resolvePromise: (value: number) => void;
415
+ const asyncAtom$ = atom(
416
+ new Promise<number>((r) => {
417
+ resolvePromise = r;
418
+ })
419
+ );
420
+
421
+ const level1$ = derived(({ read }) => read(asyncAtom$) * 2);
422
+ const level2$ = derived(({ read }) => read(level1$) + 10);
423
+ const level3$ = derived(({ read }) => read(level2$) * 3);
424
+
425
+ // All should be loading
426
+ expect(level1$.state().status).toBe("loading");
427
+ expect(level2$.state().status).toBe("loading");
428
+ expect(level3$.state().status).toBe("loading");
429
+
430
+ // Resolve
431
+ resolvePromise!(5);
432
+ await level3$.get();
433
+
434
+ // All should be ready with correct values
435
+ expect(level1$.state().status).toBe("ready");
436
+ expect(level2$.state().status).toBe("ready");
437
+ expect(level3$.state().status).toBe("ready");
438
+ expect(await level3$.get()).toBe((5 * 2 + 10) * 3); // 60
439
+ });
440
+ });
441
+
442
+ describe("no orphaned promises (Bug #2)", () => {
443
+ it("should not create orphaned promises when already loading", async () => {
444
+ // Bug: When compute() was called while already loading,
445
+ // it created a new Promise, orphaning the one React was waiting on
446
+ let resolvePromise: (value: number) => void;
447
+ const asyncAtom$ = atom(
448
+ new Promise<number>((r) => {
449
+ resolvePromise = r;
450
+ })
451
+ );
452
+
453
+ const derived$ = derived(({ read }) => read(asyncAtom$) * 2);
454
+
455
+ // Get the promise that would be thrown for Suspense
456
+ const state1 = derived$.state();
457
+ expect(state1.status).toBe("loading");
458
+ const promise1 = state1.status === "loading" ? state1.promise : null;
459
+
460
+ // Trigger another computation while still loading
461
+ derived$.refresh();
462
+
463
+ // Should return the SAME promise (not orphan the first one)
464
+ const state2 = derived$.state();
465
+ expect(state2.status).toBe("loading");
466
+ const promise2 = state2.status === "loading" ? state2.promise : null;
467
+
468
+ expect(promise1).toBe(promise2);
469
+
470
+ // Resolve and verify completion
471
+ resolvePromise!(21);
472
+ const result = await derived$.get();
473
+ expect(result).toBe(42);
474
+ });
475
+
476
+ it("should complete properly when dependency changes during loading", async () => {
477
+ let resolveFirst: (value: number) => void;
478
+ const firstPromise = new Promise<number>((r) => {
479
+ resolveFirst = r;
480
+ });
481
+
482
+ const base$ = atom(firstPromise);
483
+ const derived$ = derived(({ read }) => read(base$) * 2);
484
+
485
+ // Start loading
486
+ expect(derived$.state().status).toBe("loading");
487
+
488
+ // Simulate setting a new promise (like refetch)
489
+ let resolveSecond: (value: number) => void;
490
+ const secondPromise = new Promise<number>((r) => {
491
+ resolveSecond = r;
492
+ });
493
+ base$.set(secondPromise);
494
+
495
+ // The derived atom's existing computation is waiting on firstPromise
496
+ // When firstPromise resolves, it will retry and pick up secondPromise
497
+ // So we need to resolve BOTH promises
498
+
499
+ // Resolve first to trigger retry
500
+ resolveFirst!(5);
501
+
502
+ // Wait a tick for retry to happen
503
+ await new Promise((r) => setTimeout(r, 0));
504
+
505
+ // Now resolve the second promise
506
+ resolveSecond!(10);
507
+
508
+ // Should eventually resolve with the second value
509
+ const result = await derived$.get();
510
+ expect(result).toBe(20);
511
+ });
512
+ });
513
+
514
+ describe("notify on first resolve even when silent (Bug #3)", () => {
515
+ it("should notify subscribers when transitioning from loading to ready", async () => {
516
+ // Bug: When derived atoms were initialized with silent=true,
517
+ // they never called notify() even after promise resolved
518
+ let resolvePromise: (value: number) => void;
519
+ const asyncAtom$ = atom(
520
+ new Promise<number>((r) => {
521
+ resolvePromise = r;
522
+ })
523
+ );
524
+
525
+ const derived$ = derived(({ read }) => read(asyncAtom$) * 2);
526
+ const listener = vi.fn();
527
+
528
+ // Subscribe before resolution
529
+ derived$.on(listener);
530
+ expect(derived$.state().status).toBe("loading");
531
+
532
+ // Resolve the promise
533
+ resolvePromise!(5);
534
+ await derived$.get();
535
+
536
+ // Listener MUST be called when transitioning loading → ready
537
+ expect(listener).toHaveBeenCalled();
538
+ expect(derived$.state().status).toBe("ready");
539
+ });
540
+
541
+ it("should notify subscribers when transitioning from loading to error", async () => {
542
+ let rejectPromise: (error: Error) => void;
543
+ const asyncAtom$ = atom(
544
+ new Promise<number>((_, reject) => {
545
+ rejectPromise = reject;
546
+ })
547
+ );
548
+
549
+ const derived$ = derived(({ read }) => read(asyncAtom$) * 2);
550
+ const listener = vi.fn();
551
+
552
+ // Subscribe before rejection
553
+ derived$.on(listener);
554
+ expect(derived$.state().status).toBe("loading");
555
+
556
+ // Reject the promise
557
+ rejectPromise!(new Error("Test error"));
558
+
559
+ // Wait for rejection to be processed
560
+ try {
561
+ await derived$.get();
562
+ } catch {
563
+ // Expected
564
+ }
565
+
566
+ // Listener MUST be called when transitioning loading → error
567
+ expect(listener).toHaveBeenCalled();
568
+ expect(derived$.state().status).toBe("error");
569
+ });
570
+
571
+ it("should update state() correctly after async resolution", async () => {
572
+ // This tests the demo scenario where atoms show "Loading" forever
573
+ let resolvePromise: (value: number) => void;
574
+ const asyncAtom$ = atom(
575
+ new Promise<number>((r) => {
576
+ resolvePromise = r;
577
+ })
578
+ );
579
+
580
+ // Wrapper derived (like in the Async Utils demo)
581
+ const wrapper$ = derived(({ read }) => read(asyncAtom$));
582
+
583
+ // Initially loading
584
+ const initialState = wrapper$.state();
585
+ expect(initialState.status).toBe("loading");
586
+
587
+ // Resolve
588
+ resolvePromise!(42);
589
+ await wrapper$.get();
590
+
591
+ // State MUST reflect the resolved value
592
+ const finalState = wrapper$.state();
593
+ expect(finalState.status).toBe("ready");
594
+ if (finalState.status === "ready") {
595
+ expect(finalState.value).toBe(42);
596
+ }
597
+ });
598
+
599
+ it("should work with multiple wrapper derived atoms", async () => {
600
+ // Simulates the Async Utils demo with multiple atoms
601
+ const createAsyncAtom = (delayMs: number, value: number) => {
602
+ return atom(
603
+ new Promise<number>((resolve) => {
604
+ setTimeout(() => resolve(value), delayMs);
605
+ })
606
+ );
607
+ };
608
+
609
+ const atom1$ = createAsyncAtom(10, 1);
610
+ const atom2$ = createAsyncAtom(20, 2);
611
+ const atom3$ = createAsyncAtom(30, 3);
612
+
613
+ const wrapper1$ = derived(({ read }) => read(atom1$));
614
+ const wrapper2$ = derived(({ read }) => read(atom2$));
615
+ const wrapper3$ = derived(({ read }) => read(atom3$));
616
+
617
+ const listener1 = vi.fn();
618
+ const listener2 = vi.fn();
619
+ const listener3 = vi.fn();
620
+
621
+ wrapper1$.on(listener1);
622
+ wrapper2$.on(listener2);
623
+ wrapper3$.on(listener3);
624
+
625
+ // Wait for all to resolve
626
+ await Promise.all([wrapper1$.get(), wrapper2$.get(), wrapper3$.get()]);
627
+
628
+ // All listeners should have been called
629
+ expect(listener1).toHaveBeenCalled();
630
+ expect(listener2).toHaveBeenCalled();
631
+ expect(listener3).toHaveBeenCalled();
632
+
633
+ // All states should be ready
634
+ expect(wrapper1$.state().status).toBe("ready");
635
+ expect(wrapper2$.state().status).toBe("ready");
636
+ expect(wrapper3$.state().status).toBe("ready");
637
+ });
638
+ });
639
+ });
640
+
641
+ describe("ready() - reactive suspension", () => {
642
+ describe("basic functionality", () => {
643
+ it("should return non-null value immediately", async () => {
644
+ const id$ = atom("article-123");
645
+ const derived$ = derived(({ ready }) => {
646
+ const id = ready(id$);
647
+ return `loaded: ${id}`;
648
+ });
649
+
650
+ expect(await derived$.get()).toBe("loaded: article-123");
651
+ });
652
+
653
+ it("should suspend when value is null", async () => {
654
+ const id$ = atom<string | null>(null);
655
+ const derived$ = derived(({ ready }) => {
656
+ const id = ready(id$);
657
+ return `loaded: ${id}`;
658
+ });
659
+
660
+ // Should be in loading state (suspended)
661
+ expect(derived$.state().status).toBe("loading");
662
+ });
663
+
664
+ it("should suspend when value is undefined", async () => {
665
+ const id$ = atom<string | undefined>(undefined);
666
+ const derived$ = derived(({ ready }) => {
667
+ const id = ready(id$);
668
+ return `loaded: ${id}`;
669
+ });
670
+
671
+ expect(derived$.state().status).toBe("loading");
672
+ });
673
+
674
+ it("should NOT suspend for falsy but valid values (0, false, empty string)", async () => {
675
+ const zero$ = atom(0);
676
+ const false$ = atom(false);
677
+ const empty$ = atom("");
678
+
679
+ const zeroResult$ = derived(({ ready }) => ready(zero$));
680
+ const falseResult$ = derived(({ ready }) => ready(false$));
681
+ const emptyResult$ = derived(({ ready }) => ready(empty$));
682
+
683
+ expect(await zeroResult$.get()).toBe(0);
684
+ expect(await falseResult$.get()).toBe(false);
685
+ expect(await emptyResult$.get()).toBe("");
686
+ });
687
+ });
688
+
689
+ describe("reactive resumption", () => {
690
+ it("should resume when null value becomes non-null", async () => {
691
+ const id$ = atom<string | null>(null);
692
+ const computeCount = vi.fn();
693
+
694
+ const derived$ = derived(({ ready }) => {
695
+ computeCount();
696
+ const id = ready(id$);
697
+ return `loaded: ${id}`;
698
+ });
699
+
700
+ // Initially suspended
701
+ expect(derived$.state().status).toBe("loading");
702
+
703
+ // Set non-null value - should trigger recomputation
704
+ id$.set("article-123");
705
+
706
+ // Wait for recomputation
707
+ const result = await derived$.get();
708
+
709
+ expect(result).toBe("loaded: article-123");
710
+ expect(derived$.state().status).toBe("ready");
711
+ // Should have computed at least twice (once null, once with value)
712
+ expect(computeCount).toHaveBeenCalled();
713
+ });
714
+
715
+ it("should resume and compute with new value when dependency changes", async () => {
716
+ const id$ = atom<string | null>(null);
717
+ const derived$ = derived(({ ready }) => {
718
+ const id = ready(id$);
719
+ return id.toUpperCase();
720
+ });
721
+
722
+ // Set to first value
723
+ id$.set("hello");
724
+ expect(await derived$.get()).toBe("HELLO");
725
+
726
+ // Change to another value
727
+ id$.set("world");
728
+ expect(await derived$.get()).toBe("WORLD");
729
+
730
+ // Set back to null - should suspend again
731
+ id$.set(null);
732
+ expect(derived$.state().status).toBe("loading");
733
+
734
+ // Set to new value - should resume
735
+ id$.set("test");
736
+ expect(await derived$.get()).toBe("TEST");
737
+ });
738
+ });
739
+
740
+ describe("ready() with selector", () => {
741
+ it("should extract and return non-null property", async () => {
742
+ const user$ = atom({ id: 1, name: "John" });
743
+
744
+ const derived$ = derived(({ ready }) => {
745
+ const name = ready(user$, (u) => u.name);
746
+ return `Hello, ${name}!`;
747
+ });
748
+
749
+ expect(await derived$.get()).toBe("Hello, John!");
750
+ });
751
+
752
+ it("should suspend when selector returns null", async () => {
753
+ const user$ = atom<{ id: number; email: string | null }>({
754
+ id: 1,
755
+ email: null,
756
+ });
757
+
758
+ const derived$ = derived(({ ready }) => {
759
+ const email = ready(user$, (u) => u.email);
760
+ return `Email: ${email}`;
761
+ });
762
+
763
+ expect(derived$.state().status).toBe("loading");
764
+ });
765
+
766
+ it("should resume when selector result becomes non-null", async () => {
767
+ const user$ = atom<{ id: number; email: string | null }>({
768
+ id: 1,
769
+ email: null,
770
+ });
771
+
772
+ const derived$ = derived(({ ready }) => {
773
+ const email = ready(user$, (u) => u.email);
774
+ return `Email: ${email}`;
775
+ });
776
+
777
+ // Initially suspended
778
+ expect(derived$.state().status).toBe("loading");
779
+
780
+ // Update user with email
781
+ user$.set({ id: 1, email: "john@example.com" });
782
+
783
+ expect(await derived$.get()).toBe("Email: john@example.com");
784
+ });
785
+
786
+ it("should suspend when selector returns undefined", async () => {
787
+ const data$ = atom<{ value?: number }>({});
788
+
789
+ const derived$ = derived(({ ready }) => {
790
+ const value = ready(data$, (d) => d.value);
791
+ return value * 2;
792
+ });
793
+
794
+ expect(derived$.state().status).toBe("loading");
795
+
796
+ // Set the value
797
+ data$.set({ value: 21 });
798
+ expect(await derived$.get()).toBe(42);
799
+ });
800
+ });
801
+
802
+ describe("multiple ready() calls", () => {
803
+ it("should suspend until all ready() calls have non-null values", async () => {
804
+ const firstName$ = atom<string | null>(null);
805
+ const lastName$ = atom<string | null>(null);
806
+
807
+ const derived$ = derived(({ ready }) => {
808
+ const first = ready(firstName$);
809
+ const last = ready(lastName$);
810
+ return `${first} ${last}`;
811
+ });
812
+
813
+ // Both null - suspended
814
+ expect(derived$.state().status).toBe("loading");
815
+
816
+ // Set first name only - still suspended (lastName is null)
817
+ firstName$.set("John");
818
+ // Need to wait a tick for recomputation
819
+ await new Promise((r) => setTimeout(r, 0));
820
+ expect(derived$.state().status).toBe("loading");
821
+
822
+ // Set last name - should resolve
823
+ lastName$.set("Doe");
824
+ expect(await derived$.get()).toBe("John Doe");
825
+ });
826
+
827
+ it("should track all atoms from ready() calls as dependencies", async () => {
828
+ const a$ = atom<number | null>(null);
829
+ const b$ = atom<number | null>(null);
830
+ const listener = vi.fn();
831
+
832
+ const derived$ = derived(({ ready }) => {
833
+ const a = ready(a$);
834
+ const b = ready(b$);
835
+ return a + b;
836
+ });
837
+
838
+ derived$.on(listener);
839
+
840
+ // Set both values
841
+ a$.set(1);
842
+ b$.set(2);
843
+ await derived$.get();
844
+
845
+ // Should have been notified when resolved
846
+ expect(listener).toHaveBeenCalled();
847
+ expect(await derived$.get()).toBe(3);
848
+
849
+ // Clear listener calls
850
+ listener.mockClear();
851
+
852
+ // Change one value - should trigger recomputation
853
+ a$.set(10);
854
+ await derived$.get();
855
+ expect(listener).toHaveBeenCalled();
856
+ expect(await derived$.get()).toBe(12);
857
+ });
858
+ });
859
+
860
+ describe("combining ready() with read()", () => {
861
+ it("should allow mixing ready() and read() in same derived", async () => {
862
+ const requiredId$ = atom<string | null>(null);
863
+ const optionalName$ = atom("default");
864
+
865
+ const derived$ = derived(({ ready, read }) => {
866
+ const id = ready(requiredId$);
867
+ const name = read(optionalName$);
868
+ return { id, name };
869
+ });
870
+
871
+ // Suspended because requiredId is null
872
+ expect(derived$.state().status).toBe("loading");
873
+
874
+ // Set required value
875
+ requiredId$.set("123");
876
+ expect(await derived$.get()).toEqual({ id: "123", name: "default" });
877
+
878
+ // Change optional value
879
+ optionalName$.set("custom");
880
+ expect(await derived$.get()).toEqual({ id: "123", name: "custom" });
881
+ });
882
+
883
+ it("should suspend on ready() even if read() would succeed", async () => {
884
+ const readValue$ = atom(42);
885
+ const readyValue$ = atom<number | null>(null);
886
+
887
+ const derived$ = derived(({ ready, read }) => {
888
+ const readResult = read(readValue$);
889
+ const readyResult = ready(readyValue$);
890
+ return readResult + readyResult;
891
+ });
892
+
893
+ expect(derived$.state().status).toBe("loading");
894
+
895
+ readyValue$.set(8);
896
+ expect(await derived$.get()).toBe(50);
897
+ });
898
+ });
899
+
900
+ describe("real-world use case: current entity loading", () => {
901
+ it("should handle route-based entity loading pattern", async () => {
902
+ // Simulates /article/:id route pattern
903
+ const currentArticleId$ = atom<string | null>(null);
904
+
905
+ // Article cache
906
+ const articleCache$ = atom<Record<string, { title: string }>>({});
907
+
908
+ // Current article derived - waits for ID to be set
909
+ const currentArticle$ = derived(({ ready, read }) => {
910
+ const id = ready(currentArticleId$);
911
+ const cache = read(articleCache$);
912
+ return cache[id] ?? { title: "Not found" };
913
+ });
914
+
915
+ // Initially suspended (no article selected)
916
+ expect(currentArticle$.state().status).toBe("loading");
917
+
918
+ // User navigates to /article/123
919
+ currentArticleId$.set("123");
920
+ articleCache$.set({ "123": { title: "Hello World" } });
921
+
922
+ expect(await currentArticle$.get()).toEqual({ title: "Hello World" });
923
+
924
+ // User navigates away (deselects article)
925
+ currentArticleId$.set(null);
926
+ await new Promise((r) => setTimeout(r, 0));
927
+ expect(currentArticle$.state().status).toBe("loading");
928
+
929
+ // User navigates to /article/456
930
+ articleCache$.set({
931
+ "123": { title: "Hello World" },
932
+ "456": { title: "Another Article" },
933
+ });
934
+ currentArticleId$.set("456");
935
+
936
+ expect(await currentArticle$.get()).toEqual({
937
+ title: "Another Article",
938
+ });
939
+ });
940
+
941
+ it("should handle authentication-gated content", async () => {
942
+ const currentUser$ = atom<{ id: string; name: string } | null>(null);
943
+
944
+ const userDashboard$ = derived(({ ready }) => {
945
+ const user = ready(currentUser$);
946
+ return {
947
+ greeting: `Welcome back, ${user.name}!`,
948
+ userId: user.id,
949
+ };
950
+ });
951
+
952
+ // Not logged in - suspended
953
+ expect(userDashboard$.state().status).toBe("loading");
954
+
955
+ // User logs in
956
+ currentUser$.set({ id: "u1", name: "Alice" });
957
+
958
+ expect(await userDashboard$.get()).toEqual({
959
+ greeting: "Welcome back, Alice!",
960
+ userId: "u1",
961
+ });
962
+
963
+ // User logs out
964
+ currentUser$.set(null);
965
+ await new Promise((r) => setTimeout(r, 0));
966
+ expect(userDashboard$.state().status).toBe("loading");
967
+ });
968
+ });
969
+
970
+ describe("error handling", () => {
971
+ it("should propagate errors thrown after ready() succeeds", async () => {
972
+ const value$ = atom<number | null>(10);
973
+
974
+ const derived$ = derived(({ ready }) => {
975
+ const value = ready(value$);
976
+ if (value > 5) {
977
+ throw new Error("Value too high");
978
+ }
979
+ return value;
980
+ });
981
+
982
+ await expect(derived$.get()).rejects.toThrow("Value too high");
983
+ expect(derived$.state().status).toBe("error");
984
+ });
985
+
986
+ it("should recover from error when value changes to valid", async () => {
987
+ const value$ = atom<number | null>(10);
988
+
989
+ const derived$ = derived(({ ready }) => {
990
+ const value = ready(value$);
991
+ if (value > 5) {
992
+ throw new Error("Value too high");
993
+ }
994
+ return value * 2;
995
+ });
996
+
997
+ // First: error
998
+ await expect(derived$.get()).rejects.toThrow();
999
+
1000
+ // Change to valid value
1001
+ value$.set(3);
1002
+ expect(await derived$.get()).toBe(6);
1003
+ });
1004
+ });
1005
+
1006
+ describe("with async dependencies", () => {
1007
+ it("should wait for async atom AND ready() condition", async () => {
1008
+ let resolveAsync: (value: number) => void;
1009
+ const asyncValue$ = atom(
1010
+ new Promise<number>((r) => {
1011
+ resolveAsync = r;
1012
+ })
1013
+ );
1014
+ const readyValue$ = atom<string | null>(null);
1015
+
1016
+ const derived$ = derived(({ read, ready }) => {
1017
+ const asyncVal = read(asyncValue$);
1018
+ const readyVal = ready(readyValue$);
1019
+ return `${asyncVal}-${readyVal}`;
1020
+ });
1021
+
1022
+ // Both loading/null - suspended
1023
+ expect(derived$.state().status).toBe("loading");
1024
+
1025
+ // Resolve async - still suspended (ready is null)
1026
+ resolveAsync!(42);
1027
+ await new Promise((r) => setTimeout(r, 0));
1028
+ expect(derived$.state().status).toBe("loading");
1029
+
1030
+ // Set ready value - should resolve
1031
+ readyValue$.set("test");
1032
+ expect(await derived$.get()).toBe("42-test");
1033
+ });
1034
+ });
1035
+ });
1036
+
1037
+ describe("onError callback", () => {
1038
+ it("should call onError when computation throws synchronously", async () => {
1039
+ const onError = vi.fn();
1040
+ const source$ = atom(0);
1041
+
1042
+ const derived$ = derived(
1043
+ ({ read }) => {
1044
+ const val = read(source$);
1045
+ if (val > 0) {
1046
+ throw new Error("Value too high");
1047
+ }
1048
+ return val;
1049
+ },
1050
+ { onError }
1051
+ );
1052
+
1053
+ // Initial value - no error
1054
+ await derived$.get();
1055
+ expect(onError).not.toHaveBeenCalled();
1056
+
1057
+ // Trigger error - catch the rejection to avoid unhandled rejection warning
1058
+ source$.set(5);
1059
+ derived$.get().catch(() => {}); // Catch expected rejection
1060
+ await new Promise((r) => setTimeout(r, 0));
1061
+
1062
+ expect(onError).toHaveBeenCalledTimes(1);
1063
+ expect(onError).toHaveBeenCalledWith(expect.any(Error));
1064
+ expect((onError.mock.calls[0][0] as Error).message).toBe("Value too high");
1065
+ });
1066
+
1067
+ it("should call onError when async atom dependency rejects", async () => {
1068
+ const onError = vi.fn();
1069
+
1070
+ // Create an atom with a rejecting Promise
1071
+ const asyncSource$ = atom(Promise.reject(new Error("Async error")));
1072
+
1073
+ const derived$ = derived(
1074
+ ({ read }) => {
1075
+ return read(asyncSource$);
1076
+ },
1077
+ { onError }
1078
+ );
1079
+
1080
+ // Access to trigger computation
1081
+ derived$.get().catch(() => {}); // Catch to avoid unhandled rejection
1082
+
1083
+ await new Promise((r) => setTimeout(r, 20));
1084
+
1085
+ expect(onError).toHaveBeenCalledTimes(1);
1086
+ expect((onError.mock.calls[0][0] as Error).message).toBe("Async error");
1087
+ });
1088
+
1089
+ it("should call onError on each recomputation that throws", async () => {
1090
+ const onError = vi.fn();
1091
+ const source$ = atom(0);
1092
+
1093
+ const derived$ = derived(
1094
+ ({ read }) => {
1095
+ const val = read(source$);
1096
+ if (val > 0) {
1097
+ throw new Error(`Error for ${val}`);
1098
+ }
1099
+ return val;
1100
+ },
1101
+ { onError }
1102
+ );
1103
+
1104
+ await derived$.get();
1105
+ expect(onError).not.toHaveBeenCalled();
1106
+
1107
+ // First error - catch to avoid unhandled rejection
1108
+ source$.set(1);
1109
+ derived$.get().catch(() => {});
1110
+ await new Promise((r) => setTimeout(r, 0));
1111
+ expect(onError).toHaveBeenCalledTimes(1);
1112
+
1113
+ // Second error - catch to avoid unhandled rejection
1114
+ source$.set(2);
1115
+ derived$.get().catch(() => {});
1116
+ await new Promise((r) => setTimeout(r, 0));
1117
+ expect(onError).toHaveBeenCalledTimes(2);
1118
+ expect((onError.mock.calls[1][0] as Error).message).toBe("Error for 2");
1119
+ });
1120
+
1121
+ it("should not call onError when computation succeeds", async () => {
1122
+ const onError = vi.fn();
1123
+ const source$ = atom(5);
1124
+
1125
+ const derived$ = derived(({ read }) => read(source$) * 2, { onError });
1126
+
1127
+ await derived$.get();
1128
+ source$.set(10);
1129
+ await derived$.get();
1130
+ source$.set(15);
1131
+ await derived$.get();
1132
+
1133
+ expect(onError).not.toHaveBeenCalled();
1134
+ });
1135
+
1136
+ it("should not call onError for Promise throws (Suspense)", async () => {
1137
+ const onError = vi.fn();
1138
+ let resolvePromise: (value: number) => void;
1139
+ const asyncSource$ = atom(
1140
+ new Promise<number>((resolve) => {
1141
+ resolvePromise = resolve;
1142
+ })
1143
+ );
1144
+
1145
+ const derived$ = derived(({ read }) => read(asyncSource$) * 2, {
1146
+ onError,
1147
+ });
1148
+
1149
+ // Still loading - onError should NOT be called
1150
+ await new Promise((r) => setTimeout(r, 10));
1151
+ expect(onError).not.toHaveBeenCalled();
1152
+
1153
+ // Resolve successfully
1154
+ resolvePromise!(5);
1155
+ expect(await derived$.get()).toBe(10);
1156
+ expect(onError).not.toHaveBeenCalled();
1157
+ });
1158
+
1159
+ it("should work without onError callback", async () => {
1160
+ const source$ = atom(0);
1161
+
1162
+ const derived$ = derived(({ read }) => {
1163
+ const val = read(source$);
1164
+ if (val > 0) {
1165
+ throw new Error("Error");
1166
+ }
1167
+ return val;
1168
+ });
1169
+
1170
+ // Should not throw even without onError
1171
+ await derived$.get();
1172
+ source$.set(5);
1173
+ derived$.get().catch(() => {}); // Catch expected rejection
1174
+ await new Promise((r) => setTimeout(r, 0));
1175
+
1176
+ expect(derived$.state().status).toBe("error");
1177
+ });
1178
+
1179
+ it("should allow error recovery and call onError again on subsequent errors", async () => {
1180
+ const onError = vi.fn();
1181
+ const source$ = atom(0);
1182
+
1183
+ const derived$ = derived(
1184
+ ({ read }) => {
1185
+ const val = read(source$);
1186
+ if (val === 1) {
1187
+ throw new Error("First error");
1188
+ }
1189
+ if (val === 3) {
1190
+ throw new Error("Second error");
1191
+ }
1192
+ return val * 2;
1193
+ },
1194
+ { onError }
1195
+ );
1196
+
1197
+ await derived$.get(); // 0 -> success
1198
+ expect(onError).not.toHaveBeenCalled();
1199
+
1200
+ source$.set(1); // error
1201
+ derived$.get().catch(() => {}); // Catch expected rejection
1202
+ await new Promise((r) => setTimeout(r, 0));
1203
+ expect(onError).toHaveBeenCalledTimes(1);
1204
+
1205
+ source$.set(2); // recover
1206
+ expect(await derived$.get()).toBe(4);
1207
+ expect(onError).toHaveBeenCalledTimes(1); // still 1
1208
+
1209
+ source$.set(3); // error again
1210
+ derived$.get().catch(() => {}); // Catch expected rejection
1211
+ await new Promise((r) => setTimeout(r, 0));
1212
+ expect(onError).toHaveBeenCalledTimes(2);
1213
+ });
1214
+ });
381
1215
  });