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.
- package/README.md +198 -2234
- package/bin/cli.js +90 -0
- package/dist/core/derived.d.ts +2 -2
- package/dist/core/effect.d.ts +3 -2
- package/dist/core/onCreateHook.d.ts +15 -2
- package/dist/core/onErrorHook.d.ts +4 -1
- package/dist/core/pool.d.ts +78 -0
- package/dist/core/pool.test.d.ts +1 -0
- package/dist/core/select-boolean.test.d.ts +1 -0
- package/dist/core/select-pool.test.d.ts +1 -0
- package/dist/core/select.d.ts +278 -86
- package/dist/core/types.d.ts +233 -1
- package/dist/core/withAbort.d.ts +95 -0
- package/dist/core/withReady.d.ts +3 -3
- package/dist/devtools/constants.d.ts +41 -0
- package/dist/devtools/index.cjs +1 -0
- package/dist/devtools/index.d.ts +29 -0
- package/dist/devtools/index.js +429 -0
- package/dist/devtools/registry.d.ts +98 -0
- package/dist/devtools/registry.test.d.ts +1 -0
- package/dist/devtools/setup.d.ts +61 -0
- package/dist/devtools/types.d.ts +311 -0
- package/dist/index-BZEnfIcB.cjs +1 -0
- package/dist/index-BbPZhsDl.js +1653 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +18 -14
- package/dist/onDispatchHook-C8yLzr-o.cjs +1 -0
- package/dist/onDispatchHook-SKbiIUaJ.js +5 -0
- package/dist/onErrorHook-BGGy3tqK.js +38 -0
- package/dist/onErrorHook-DHBASmYw.cjs +1 -0
- package/dist/react/index.cjs +1 -1
- package/dist/react/index.js +191 -151
- package/dist/react/onDispatchHook.d.ts +106 -0
- package/dist/react/useAction.d.ts +4 -1
- package/dist/react-devtools/DevToolsPanel.d.ts +93 -0
- package/dist/react-devtools/EntityDetails.d.ts +10 -0
- package/dist/react-devtools/EntityList.d.ts +15 -0
- package/dist/react-devtools/LogList.d.ts +12 -0
- package/dist/react-devtools/hooks.d.ts +50 -0
- package/dist/react-devtools/index.cjs +1 -0
- package/dist/react-devtools/index.d.ts +31 -0
- package/dist/react-devtools/index.js +1589 -0
- package/dist/react-devtools/styles.d.ts +148 -0
- package/package.json +26 -2
- package/skills/atomirx/SKILL.md +456 -0
- package/skills/atomirx/references/async-patterns.md +188 -0
- package/skills/atomirx/references/atom-patterns.md +238 -0
- package/skills/atomirx/references/deferred-loading.md +191 -0
- package/skills/atomirx/references/derived-patterns.md +428 -0
- package/skills/atomirx/references/effect-patterns.md +426 -0
- package/skills/atomirx/references/error-handling.md +140 -0
- package/skills/atomirx/references/hooks.md +322 -0
- package/skills/atomirx/references/pool-patterns.md +229 -0
- package/skills/atomirx/references/react-integration.md +411 -0
- package/skills/atomirx/references/rules.md +407 -0
- package/skills/atomirx/references/select-context.md +309 -0
- package/skills/atomirx/references/service-template.md +172 -0
- package/skills/atomirx/references/store-template.md +205 -0
- package/skills/atomirx/references/testing-patterns.md +431 -0
- package/coverage/base.css +0 -224
- package/coverage/block-navigation.js +0 -87
- package/coverage/clover.xml +0 -1440
- package/coverage/coverage-final.json +0 -14
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +0 -131
- package/coverage/prettify.css +0 -1
- package/coverage/prettify.js +0 -2
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +0 -210
- package/coverage/src/core/atom.ts.html +0 -889
- package/coverage/src/core/batch.ts.html +0 -223
- package/coverage/src/core/define.ts.html +0 -805
- package/coverage/src/core/emitter.ts.html +0 -919
- package/coverage/src/core/equality.ts.html +0 -631
- package/coverage/src/core/hook.ts.html +0 -460
- package/coverage/src/core/index.html +0 -281
- package/coverage/src/core/isAtom.ts.html +0 -100
- package/coverage/src/core/isPromiseLike.ts.html +0 -133
- package/coverage/src/core/onCreateHook.ts.html +0 -138
- package/coverage/src/core/scheduleNotifyHook.ts.html +0 -94
- package/coverage/src/core/types.ts.html +0 -523
- package/coverage/src/core/withUse.ts.html +0 -253
- package/coverage/src/index.html +0 -116
- package/coverage/src/index.ts.html +0 -106
- package/dist/index-CBVj1kSj.js +0 -1350
- package/dist/index-Cxk9v0um.cjs +0 -1
- package/scripts/publish.js +0 -198
- package/src/core/atom.test.ts +0 -633
- package/src/core/atom.ts +0 -311
- package/src/core/atomState.test.ts +0 -342
- package/src/core/atomState.ts +0 -256
- package/src/core/batch.test.ts +0 -257
- package/src/core/batch.ts +0 -172
- package/src/core/define.test.ts +0 -343
- package/src/core/define.ts +0 -243
- package/src/core/derived.test.ts +0 -1215
- package/src/core/derived.ts +0 -450
- package/src/core/effect.test.ts +0 -802
- package/src/core/effect.ts +0 -188
- package/src/core/emitter.test.ts +0 -364
- package/src/core/emitter.ts +0 -392
- package/src/core/equality.test.ts +0 -392
- package/src/core/equality.ts +0 -182
- package/src/core/getAtomState.ts +0 -69
- package/src/core/hook.test.ts +0 -227
- package/src/core/hook.ts +0 -177
- package/src/core/isAtom.ts +0 -27
- package/src/core/isPromiseLike.test.ts +0 -72
- package/src/core/isPromiseLike.ts +0 -16
- package/src/core/onCreateHook.ts +0 -107
- package/src/core/onErrorHook.test.ts +0 -350
- package/src/core/onErrorHook.ts +0 -52
- package/src/core/promiseCache.test.ts +0 -241
- package/src/core/promiseCache.ts +0 -284
- package/src/core/scheduleNotifyHook.ts +0 -53
- package/src/core/select.ts +0 -729
- package/src/core/selector.test.ts +0 -799
- package/src/core/types.ts +0 -389
- package/src/core/withReady.test.ts +0 -534
- package/src/core/withReady.ts +0 -191
- package/src/core/withUse.test.ts +0 -249
- package/src/core/withUse.ts +0 -56
- package/src/index.test.ts +0 -80
- package/src/index.ts +0 -65
- package/src/react/index.ts +0 -21
- package/src/react/rx.test.tsx +0 -571
- package/src/react/rx.tsx +0 -531
- package/src/react/strictModeTest.tsx +0 -71
- package/src/react/useAction.test.ts +0 -987
- package/src/react/useAction.ts +0 -607
- package/src/react/useSelector.test.ts +0 -182
- package/src/react/useSelector.ts +0 -292
- package/src/react/useStable.test.ts +0 -553
- package/src/react/useStable.ts +0 -288
- package/tsconfig.json +0 -9
- package/v2.md +0 -725
- package/vite.config.ts +0 -42
package/src/core/derived.test.ts
DELETED
|
@@ -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
|
-
});
|