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