atomirx 0.0.8 → 0.1.1

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 (138) hide show
  1. package/README.md +198 -2234
  2. package/bin/cli.js +90 -0
  3. package/dist/core/derived.d.ts +2 -2
  4. package/dist/core/effect.d.ts +3 -2
  5. package/dist/core/onCreateHook.d.ts +15 -2
  6. package/dist/core/onErrorHook.d.ts +4 -1
  7. package/dist/core/pool.d.ts +78 -0
  8. package/dist/core/pool.test.d.ts +1 -0
  9. package/dist/core/select-boolean.test.d.ts +1 -0
  10. package/dist/core/select-pool.test.d.ts +1 -0
  11. package/dist/core/select.d.ts +278 -86
  12. package/dist/core/types.d.ts +233 -1
  13. package/dist/core/withAbort.d.ts +95 -0
  14. package/dist/core/withReady.d.ts +3 -3
  15. package/dist/devtools/constants.d.ts +41 -0
  16. package/dist/devtools/index.cjs +1 -0
  17. package/dist/devtools/index.d.ts +29 -0
  18. package/dist/devtools/index.js +429 -0
  19. package/dist/devtools/registry.d.ts +98 -0
  20. package/dist/devtools/registry.test.d.ts +1 -0
  21. package/dist/devtools/setup.d.ts +61 -0
  22. package/dist/devtools/types.d.ts +311 -0
  23. package/dist/index-BZEnfIcB.cjs +1 -0
  24. package/dist/index-BbPZhsDl.js +1653 -0
  25. package/dist/index.cjs +1 -1
  26. package/dist/index.d.ts +4 -3
  27. package/dist/index.js +18 -14
  28. package/dist/onDispatchHook-C8yLzr-o.cjs +1 -0
  29. package/dist/onDispatchHook-SKbiIUaJ.js +5 -0
  30. package/dist/onErrorHook-BGGy3tqK.js +38 -0
  31. package/dist/onErrorHook-DHBASmYw.cjs +1 -0
  32. package/dist/react/index.cjs +1 -1
  33. package/dist/react/index.js +191 -151
  34. package/dist/react/onDispatchHook.d.ts +106 -0
  35. package/dist/react/useAction.d.ts +4 -1
  36. package/dist/react-devtools/DevToolsPanel.d.ts +93 -0
  37. package/dist/react-devtools/EntityDetails.d.ts +10 -0
  38. package/dist/react-devtools/EntityList.d.ts +15 -0
  39. package/dist/react-devtools/LogList.d.ts +12 -0
  40. package/dist/react-devtools/hooks.d.ts +50 -0
  41. package/dist/react-devtools/index.cjs +1 -0
  42. package/dist/react-devtools/index.d.ts +31 -0
  43. package/dist/react-devtools/index.js +1589 -0
  44. package/dist/react-devtools/styles.d.ts +148 -0
  45. package/package.json +26 -2
  46. package/skills/atomirx/SKILL.md +456 -0
  47. package/skills/atomirx/references/async-patterns.md +188 -0
  48. package/skills/atomirx/references/atom-patterns.md +238 -0
  49. package/skills/atomirx/references/deferred-loading.md +191 -0
  50. package/skills/atomirx/references/derived-patterns.md +428 -0
  51. package/skills/atomirx/references/effect-patterns.md +426 -0
  52. package/skills/atomirx/references/error-handling.md +140 -0
  53. package/skills/atomirx/references/hooks.md +322 -0
  54. package/skills/atomirx/references/pool-patterns.md +229 -0
  55. package/skills/atomirx/references/react-integration.md +411 -0
  56. package/skills/atomirx/references/rules.md +407 -0
  57. package/skills/atomirx/references/select-context.md +309 -0
  58. package/skills/atomirx/references/service-template.md +172 -0
  59. package/skills/atomirx/references/store-template.md +205 -0
  60. package/skills/atomirx/references/testing-patterns.md +431 -0
  61. package/coverage/base.css +0 -224
  62. package/coverage/block-navigation.js +0 -87
  63. package/coverage/clover.xml +0 -1440
  64. package/coverage/coverage-final.json +0 -14
  65. package/coverage/favicon.png +0 -0
  66. package/coverage/index.html +0 -131
  67. package/coverage/prettify.css +0 -1
  68. package/coverage/prettify.js +0 -2
  69. package/coverage/sort-arrow-sprite.png +0 -0
  70. package/coverage/sorter.js +0 -210
  71. package/coverage/src/core/atom.ts.html +0 -889
  72. package/coverage/src/core/batch.ts.html +0 -223
  73. package/coverage/src/core/define.ts.html +0 -805
  74. package/coverage/src/core/emitter.ts.html +0 -919
  75. package/coverage/src/core/equality.ts.html +0 -631
  76. package/coverage/src/core/hook.ts.html +0 -460
  77. package/coverage/src/core/index.html +0 -281
  78. package/coverage/src/core/isAtom.ts.html +0 -100
  79. package/coverage/src/core/isPromiseLike.ts.html +0 -133
  80. package/coverage/src/core/onCreateHook.ts.html +0 -138
  81. package/coverage/src/core/scheduleNotifyHook.ts.html +0 -94
  82. package/coverage/src/core/types.ts.html +0 -523
  83. package/coverage/src/core/withUse.ts.html +0 -253
  84. package/coverage/src/index.html +0 -116
  85. package/coverage/src/index.ts.html +0 -106
  86. package/dist/index-CBVj1kSj.js +0 -1350
  87. package/dist/index-Cxk9v0um.cjs +0 -1
  88. package/scripts/publish.js +0 -198
  89. package/src/core/atom.test.ts +0 -633
  90. package/src/core/atom.ts +0 -311
  91. package/src/core/atomState.test.ts +0 -342
  92. package/src/core/atomState.ts +0 -256
  93. package/src/core/batch.test.ts +0 -257
  94. package/src/core/batch.ts +0 -172
  95. package/src/core/define.test.ts +0 -343
  96. package/src/core/define.ts +0 -243
  97. package/src/core/derived.test.ts +0 -1215
  98. package/src/core/derived.ts +0 -450
  99. package/src/core/effect.test.ts +0 -802
  100. package/src/core/effect.ts +0 -188
  101. package/src/core/emitter.test.ts +0 -364
  102. package/src/core/emitter.ts +0 -392
  103. package/src/core/equality.test.ts +0 -392
  104. package/src/core/equality.ts +0 -182
  105. package/src/core/getAtomState.ts +0 -69
  106. package/src/core/hook.test.ts +0 -227
  107. package/src/core/hook.ts +0 -177
  108. package/src/core/isAtom.ts +0 -27
  109. package/src/core/isPromiseLike.test.ts +0 -72
  110. package/src/core/isPromiseLike.ts +0 -16
  111. package/src/core/onCreateHook.ts +0 -107
  112. package/src/core/onErrorHook.test.ts +0 -350
  113. package/src/core/onErrorHook.ts +0 -52
  114. package/src/core/promiseCache.test.ts +0 -241
  115. package/src/core/promiseCache.ts +0 -284
  116. package/src/core/scheduleNotifyHook.ts +0 -53
  117. package/src/core/select.ts +0 -729
  118. package/src/core/selector.test.ts +0 -799
  119. package/src/core/types.ts +0 -389
  120. package/src/core/withReady.test.ts +0 -534
  121. package/src/core/withReady.ts +0 -191
  122. package/src/core/withUse.test.ts +0 -249
  123. package/src/core/withUse.ts +0 -56
  124. package/src/index.test.ts +0 -80
  125. package/src/index.ts +0 -65
  126. package/src/react/index.ts +0 -21
  127. package/src/react/rx.test.tsx +0 -571
  128. package/src/react/rx.tsx +0 -531
  129. package/src/react/strictModeTest.tsx +0 -71
  130. package/src/react/useAction.test.ts +0 -987
  131. package/src/react/useAction.ts +0 -607
  132. package/src/react/useSelector.test.ts +0 -182
  133. package/src/react/useSelector.ts +0 -292
  134. package/src/react/useStable.test.ts +0 -553
  135. package/src/react/useStable.ts +0 -288
  136. package/tsconfig.json +0 -9
  137. package/v2.md +0 -725
  138. package/vite.config.ts +0 -42
@@ -1,1215 +0,0 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import { atom } from "./atom";
3
- import { derived } from "./derived";
4
- import { SYMBOL_ATOM, SYMBOL_DERIVED } from "./types";
5
-
6
- describe("derived", () => {
7
- describe("basic functionality", () => {
8
- it("should create a derived atom from a source atom", async () => {
9
- const count$ = atom(5);
10
- const doubled$ = derived(({ read }) => read(count$) * 2);
11
-
12
- expect(await doubled$.get()).toBe(10);
13
- });
14
-
15
- it("should have SYMBOL_ATOM and SYMBOL_DERIVED markers", () => {
16
- const count$ = atom(0);
17
- const doubled$ = derived(({ read }) => read(count$) * 2);
18
-
19
- expect(doubled$[SYMBOL_ATOM]).toBe(true);
20
- expect(doubled$[SYMBOL_DERIVED]).toBe(true);
21
- });
22
-
23
- it("should always return a Promise from .get()", () => {
24
- const count$ = atom(5);
25
- const doubled$ = derived(({ read }) => read(count$) * 2);
26
-
27
- expect(doubled$.get()).toBeInstanceOf(Promise);
28
- });
29
-
30
- it("should update when source atom changes", async () => {
31
- const count$ = atom(5);
32
- const doubled$ = derived(({ read }) => read(count$) * 2);
33
-
34
- expect(await doubled$.get()).toBe(10);
35
- count$.set(10);
36
- expect(await doubled$.get()).toBe(20);
37
- });
38
-
39
- it("should derive from multiple atoms", async () => {
40
- const a$ = atom(2);
41
- const b$ = atom(3);
42
- const sum$ = derived(({ read }) => read(a$) + read(b$));
43
-
44
- expect(await sum$.get()).toBe(5);
45
- a$.set(10);
46
- expect(await sum$.get()).toBe(13);
47
- b$.set(7);
48
- expect(await sum$.get()).toBe(17);
49
- });
50
- });
51
-
52
- describe("staleValue", () => {
53
- it("should return undefined initially without fallback", async () => {
54
- const count$ = atom(5);
55
- const doubled$ = derived(({ read }) => read(count$) * 2);
56
-
57
- // Before resolution, staleValue is undefined (no fallback)
58
- // After resolution, it becomes the resolved value
59
- await doubled$.get();
60
- expect(doubled$.staleValue).toBe(10);
61
- });
62
-
63
- it("should return fallback value initially with fallback for async", async () => {
64
- // For sync atoms, computation is immediate so staleValue is already resolved
65
- // Test with async dependency to verify fallback behavior
66
- const asyncValue$ = atom(new Promise<number>(() => {})); // Never resolves
67
- const derived$ = derived(({ read }) => read(asyncValue$) * 2, {
68
- fallback: 0,
69
- });
70
-
71
- // With async dependency that's loading, state should be loading and staleValue should be fallback
72
- expect(derived$.state().status).toBe("loading");
73
- expect(derived$.staleValue).toBe(0);
74
- });
75
-
76
- it("should return resolved value for sync computation", async () => {
77
- const count$ = atom(5);
78
- const doubled$ = derived(({ read }) => read(count$) * 2, { fallback: 0 });
79
-
80
- // Sync computation resolves immediately
81
- await doubled$.get();
82
- expect(doubled$.staleValue).toBe(10);
83
- });
84
-
85
- it("should update staleValue after resolution", async () => {
86
- const count$ = atom(5);
87
- const doubled$ = derived(({ read }) => read(count$) * 2, { fallback: 0 });
88
-
89
- await doubled$.get();
90
- expect(doubled$.staleValue).toBe(10);
91
-
92
- count$.set(20);
93
- // After recomputation
94
- await doubled$.get();
95
- expect(doubled$.staleValue).toBe(40);
96
- });
97
- });
98
-
99
- describe("state", () => {
100
- it("should return loading status during loading", async () => {
101
- const asyncValue$ = atom(new Promise<number>(() => {})); // Never resolves
102
- const doubled$ = derived(({ read }) => read(asyncValue$) * 2);
103
-
104
- const state = doubled$.state();
105
- expect(state.status).toBe("loading");
106
- });
107
-
108
- it("should return loading status with fallback during loading", async () => {
109
- const asyncValue$ = atom(new Promise<number>(() => {})); // Never resolves
110
- const doubled$ = derived(({ read }) => read(asyncValue$) * 2, {
111
- fallback: 0,
112
- });
113
-
114
- // Has fallback but state is still loading (use staleValue for fallback)
115
- const state = doubled$.state();
116
- expect(state.status).toBe("loading");
117
- expect(doubled$.staleValue).toBe(0);
118
- });
119
-
120
- it("should return ready status after resolved", async () => {
121
- const count$ = atom(5);
122
- const doubled$ = derived(({ read }) => read(count$) * 2, { fallback: 0 });
123
-
124
- // Sync computation resolves immediately
125
- await doubled$.get();
126
-
127
- const state = doubled$.state();
128
- expect(state.status).toBe("ready");
129
- if (state.status === "ready") {
130
- expect(state.value).toBe(10);
131
- }
132
- });
133
-
134
- it("should return error status on error", async () => {
135
- const error = new Error("Test error");
136
- const count$ = atom(5);
137
- const willThrow$ = derived(({ read }) => {
138
- if (read(count$) > 3) {
139
- throw error;
140
- }
141
- return read(count$);
142
- });
143
-
144
- // Wait for computation to complete
145
- try {
146
- await willThrow$.get();
147
- } catch {
148
- // Expected to throw
149
- }
150
-
151
- const state = willThrow$.state();
152
- expect(state.status).toBe("error");
153
- if (state.status === "error") {
154
- expect(state.error).toBe(error);
155
- }
156
- });
157
-
158
- it("should transition from loading to ready", async () => {
159
- let resolvePromise: (value: number) => void;
160
- const asyncValue$ = atom(
161
- new Promise<number>((resolve) => {
162
- resolvePromise = resolve;
163
- })
164
- );
165
- const doubled$ = derived(({ read }) => read(asyncValue$) * 2, {
166
- fallback: 0,
167
- });
168
-
169
- // Initially loading
170
- expect(doubled$.state().status).toBe("loading");
171
- expect(doubled$.staleValue).toBe(0);
172
-
173
- // Resolve the promise
174
- resolvePromise!(5);
175
- await doubled$.get();
176
-
177
- // Now ready
178
- const state = doubled$.state();
179
- expect(state.status).toBe("ready");
180
- if (state.status === "ready") {
181
- expect(state.value).toBe(10);
182
- }
183
- expect(doubled$.staleValue).toBe(10);
184
- });
185
- });
186
-
187
- describe("refresh", () => {
188
- it("should re-run computation on refresh", async () => {
189
- let callCount = 0;
190
- const count$ = atom(5);
191
- const doubled$ = derived(({ read }) => {
192
- callCount++;
193
- return read(count$) * 2;
194
- });
195
-
196
- await doubled$.get();
197
- expect(callCount).toBeGreaterThanOrEqual(1);
198
-
199
- const countBefore = callCount;
200
- doubled$.refresh();
201
- await doubled$.get();
202
- expect(callCount).toBeGreaterThan(countBefore);
203
- });
204
- });
205
-
206
- describe("subscriptions", () => {
207
- it("should notify subscribers when derived value changes", async () => {
208
- const count$ = atom(5);
209
- const doubled$ = derived(({ read }) => read(count$) * 2);
210
- const listener = vi.fn();
211
-
212
- await doubled$.get(); // Initialize
213
- doubled$.on(listener);
214
-
215
- count$.set(10);
216
- await doubled$.get(); // Wait for recomputation
217
-
218
- expect(listener).toHaveBeenCalled();
219
- });
220
-
221
- it("should not notify if derived value is the same", async () => {
222
- const count$ = atom(5);
223
- const clamped$ = derived(({ read }) => Math.min(read(count$), 10));
224
- const listener = vi.fn();
225
-
226
- await clamped$.get();
227
- clamped$.on(listener);
228
-
229
- // Value is already clamped to 10
230
- count$.set(15); // Still clamps to 10
231
- await clamped$.get();
232
-
233
- // Should still notify because we can't detect same output without full tracking
234
- // This depends on implementation - adjust expectation as needed
235
- });
236
-
237
- it("should allow unsubscribing", async () => {
238
- const count$ = atom(5);
239
- const doubled$ = derived(({ read }) => read(count$) * 2);
240
- const listener = vi.fn();
241
-
242
- await doubled$.get();
243
- const unsub = doubled$.on(listener);
244
-
245
- count$.set(10);
246
- await doubled$.get();
247
- const callCount = listener.mock.calls.length;
248
-
249
- unsub();
250
-
251
- count$.set(20);
252
- await doubled$.get();
253
-
254
- // Should not receive more calls after unsubscribe
255
- expect(listener.mock.calls.length).toBe(callCount);
256
- });
257
- });
258
-
259
- describe("conditional dependencies", () => {
260
- it("should only subscribe to accessed atoms", async () => {
261
- const showDetails$ = atom(false);
262
- const summary$ = atom("Brief");
263
- const details$ = atom("Detailed");
264
-
265
- const content$ = derived(({ read }) =>
266
- read(showDetails$) ? read(details$) : read(summary$)
267
- );
268
-
269
- const listener = vi.fn();
270
- await content$.get();
271
- content$.on(listener);
272
-
273
- // Initially showing summary, so details changes shouldn't trigger
274
- // (This depends on implementation - conditional deps may or may not
275
- // unsubscribe from unaccessed atoms)
276
-
277
- expect(await content$.get()).toBe("Brief");
278
-
279
- showDetails$.set(true);
280
- expect(await content$.get()).toBe("Detailed");
281
- });
282
- });
283
-
284
- describe("async dependencies", () => {
285
- it("should handle atoms storing Promises", async () => {
286
- const asyncValue$ = atom(Promise.resolve(42));
287
- const doubled$ = derived(({ read }) => {
288
- const value = read(asyncValue$);
289
- // At this point, read() will throw the Promise if pending
290
- // which is handled by derived's internal retry mechanism
291
- return value;
292
- });
293
-
294
- // The derived computation handles the async dependency
295
- // This test verifies the basic wiring works
296
- await doubled$.get();
297
- // Result depends on how promiseCache tracks the Promise
298
- expect(true).toBe(true); // Test passes if no error thrown
299
- });
300
- });
301
-
302
- describe("error handling", () => {
303
- it("should propagate errors from computation", async () => {
304
- const count$ = atom(5);
305
- const willThrow$ = derived(({ read }) => {
306
- if (read(count$) > 10) {
307
- throw new Error("Value too high");
308
- }
309
- return read(count$);
310
- });
311
-
312
- expect(await willThrow$.get()).toBe(5);
313
-
314
- count$.set(15);
315
- await expect(willThrow$.get()).rejects.toThrow("Value too high");
316
- });
317
- });
318
-
319
- describe("context utilities", () => {
320
- it("should support all() for multiple atoms", async () => {
321
- const a$ = atom(1);
322
- const b$ = atom(2);
323
- const c$ = atom(3);
324
-
325
- const sum$ = derived(({ all }) => {
326
- const [a, b, c] = all([a$, b$, c$]);
327
- return a + b + c;
328
- });
329
-
330
- expect(await sum$.get()).toBe(6);
331
- });
332
-
333
- it("should support read() chaining", async () => {
334
- const a$ = atom(2);
335
- const b$ = atom(3);
336
-
337
- const result$ = derived(({ read }) => {
338
- const a = read(a$);
339
- const b = read(b$);
340
- return a * b;
341
- });
342
-
343
- expect(await result$.get()).toBe(6);
344
- });
345
- });
346
-
347
- describe("equality options", () => {
348
- it("should use strict equality by default", async () => {
349
- const source$ = atom({ a: 1 });
350
- const derived$ = derived(({ read }) => ({ ...read(source$) }));
351
- const listener = vi.fn();
352
-
353
- await derived$.get();
354
- derived$.on(listener);
355
-
356
- source$.set({ a: 1 }); // Same content, different reference
357
- await derived$.get();
358
-
359
- // With strict equality on derived output, listener should be called
360
- // because we return a new object each time
361
- expect(listener).toHaveBeenCalled();
362
- });
363
-
364
- it("should support shallow equality option", async () => {
365
- const source$ = atom({ a: 1 });
366
- const derived$ = derived(({ read }) => ({ ...read(source$) }), {
367
- equals: "shallow",
368
- });
369
- const listener = vi.fn();
370
-
371
- await derived$.get();
372
- derived$.on(listener);
373
-
374
- source$.set({ a: 1 }); // Same content
375
- await derived$.get();
376
-
377
- // With shallow equality, same content should not notify
378
- // (depends on whether source triggers derived recomputation)
379
- });
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
- });
1215
- });