atomirx 0.0.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 +1666 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/clover.xml +1440 -0
- package/coverage/coverage-final.json +14 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +131 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/core/atom.ts.html +889 -0
- package/coverage/src/core/batch.ts.html +223 -0
- package/coverage/src/core/define.ts.html +805 -0
- package/coverage/src/core/emitter.ts.html +919 -0
- package/coverage/src/core/equality.ts.html +631 -0
- package/coverage/src/core/hook.ts.html +460 -0
- package/coverage/src/core/index.html +281 -0
- package/coverage/src/core/isAtom.ts.html +100 -0
- package/coverage/src/core/isPromiseLike.ts.html +133 -0
- package/coverage/src/core/onCreateHook.ts.html +136 -0
- package/coverage/src/core/scheduleNotifyHook.ts.html +94 -0
- package/coverage/src/core/types.ts.html +523 -0
- package/coverage/src/core/withUse.ts.html +253 -0
- package/coverage/src/index.html +116 -0
- package/coverage/src/index.ts.html +106 -0
- package/dist/core/atom.d.ts +63 -0
- package/dist/core/atom.test.d.ts +1 -0
- package/dist/core/atomState.d.ts +104 -0
- package/dist/core/atomState.test.d.ts +1 -0
- package/dist/core/batch.d.ts +126 -0
- package/dist/core/batch.test.d.ts +1 -0
- package/dist/core/define.d.ts +173 -0
- package/dist/core/define.test.d.ts +1 -0
- package/dist/core/derived.d.ts +102 -0
- package/dist/core/derived.test.d.ts +1 -0
- package/dist/core/effect.d.ts +120 -0
- package/dist/core/effect.test.d.ts +1 -0
- package/dist/core/emitter.d.ts +237 -0
- package/dist/core/emitter.test.d.ts +1 -0
- package/dist/core/equality.d.ts +62 -0
- package/dist/core/equality.test.d.ts +1 -0
- package/dist/core/hook.d.ts +134 -0
- package/dist/core/hook.test.d.ts +1 -0
- package/dist/core/isAtom.d.ts +9 -0
- package/dist/core/isPromiseLike.d.ts +9 -0
- package/dist/core/isPromiseLike.test.d.ts +1 -0
- package/dist/core/onCreateHook.d.ts +79 -0
- package/dist/core/promiseCache.d.ts +134 -0
- package/dist/core/promiseCache.test.d.ts +1 -0
- package/dist/core/scheduleNotifyHook.d.ts +51 -0
- package/dist/core/select.d.ts +151 -0
- package/dist/core/selector.test.d.ts +1 -0
- package/dist/core/types.d.ts +279 -0
- package/dist/core/withUse.d.ts +38 -0
- package/dist/core/withUse.test.d.ts +1 -0
- package/dist/index-2ok7ilik.js +1217 -0
- package/dist/index-B_5SFzfl.cjs +1 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +20 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/react/index.cjs +30 -0
- package/dist/react/index.d.ts +7 -0
- package/dist/react/index.js +823 -0
- package/dist/react/rx.d.ts +250 -0
- package/dist/react/rx.test.d.ts +1 -0
- package/dist/react/strictModeTest.d.ts +10 -0
- package/dist/react/useAction.d.ts +381 -0
- package/dist/react/useAction.test.d.ts +1 -0
- package/dist/react/useStable.d.ts +183 -0
- package/dist/react/useStable.test.d.ts +1 -0
- package/dist/react/useValue.d.ts +134 -0
- package/dist/react/useValue.test.d.ts +1 -0
- package/package.json +57 -0
- package/scripts/publish.js +198 -0
- package/src/core/atom.test.ts +369 -0
- package/src/core/atom.ts +189 -0
- package/src/core/atomState.test.ts +342 -0
- package/src/core/atomState.ts +256 -0
- package/src/core/batch.test.ts +257 -0
- package/src/core/batch.ts +172 -0
- package/src/core/define.test.ts +342 -0
- package/src/core/define.ts +243 -0
- package/src/core/derived.test.ts +381 -0
- package/src/core/derived.ts +339 -0
- package/src/core/effect.test.ts +196 -0
- package/src/core/effect.ts +184 -0
- package/src/core/emitter.test.ts +364 -0
- package/src/core/emitter.ts +392 -0
- package/src/core/equality.test.ts +392 -0
- package/src/core/equality.ts +182 -0
- package/src/core/hook.test.ts +227 -0
- package/src/core/hook.ts +177 -0
- package/src/core/isAtom.ts +27 -0
- package/src/core/isPromiseLike.test.ts +72 -0
- package/src/core/isPromiseLike.ts +16 -0
- package/src/core/onCreateHook.ts +92 -0
- package/src/core/promiseCache.test.ts +239 -0
- package/src/core/promiseCache.ts +279 -0
- package/src/core/scheduleNotifyHook.ts +53 -0
- package/src/core/select.ts +454 -0
- package/src/core/selector.test.ts +257 -0
- package/src/core/types.ts +311 -0
- package/src/core/withUse.test.ts +249 -0
- package/src/core/withUse.ts +56 -0
- package/src/index.test.ts +80 -0
- package/src/index.ts +51 -0
- package/src/react/index.ts +20 -0
- package/src/react/rx.test.tsx +416 -0
- package/src/react/rx.tsx +300 -0
- package/src/react/strictModeTest.tsx +71 -0
- package/src/react/useAction.test.ts +989 -0
- package/src/react/useAction.ts +605 -0
- package/src/react/useStable.test.ts +553 -0
- package/src/react/useStable.ts +288 -0
- package/src/react/useValue.test.ts +182 -0
- package/src/react/useValue.ts +261 -0
- package/tsconfig.json +9 -0
- package/v2.md +725 -0
- package/vite.config.ts +39 -0
|
@@ -0,0 +1,381 @@
|
|
|
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(({ get }) => get(count$) * 2);
|
|
11
|
+
|
|
12
|
+
expect(await doubled$.value).toBe(10);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("should have SYMBOL_ATOM and SYMBOL_DERIVED markers", () => {
|
|
16
|
+
const count$ = atom(0);
|
|
17
|
+
const doubled$ = derived(({ get }) => get(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 .value", () => {
|
|
24
|
+
const count$ = atom(5);
|
|
25
|
+
const doubled$ = derived(({ get }) => get(count$) * 2);
|
|
26
|
+
|
|
27
|
+
expect(doubled$.value).toBeInstanceOf(Promise);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should update when source atom changes", async () => {
|
|
31
|
+
const count$ = atom(5);
|
|
32
|
+
const doubled$ = derived(({ get }) => get(count$) * 2);
|
|
33
|
+
|
|
34
|
+
expect(await doubled$.value).toBe(10);
|
|
35
|
+
count$.set(10);
|
|
36
|
+
expect(await doubled$.value).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(({ get }) => get(a$) + get(b$));
|
|
43
|
+
|
|
44
|
+
expect(await sum$.value).toBe(5);
|
|
45
|
+
a$.set(10);
|
|
46
|
+
expect(await sum$.value).toBe(13);
|
|
47
|
+
b$.set(7);
|
|
48
|
+
expect(await sum$.value).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(({ get }) => get(count$) * 2);
|
|
56
|
+
|
|
57
|
+
// Before resolution, staleValue is undefined (no fallback)
|
|
58
|
+
// After resolution, it becomes the resolved value
|
|
59
|
+
await doubled$.value;
|
|
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(({ get }) => get(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(({ get }) => get(count$) * 2, { fallback: 0 });
|
|
79
|
+
|
|
80
|
+
// Sync computation resolves immediately
|
|
81
|
+
await doubled$.value;
|
|
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(({ get }) => get(count$) * 2, { fallback: 0 });
|
|
88
|
+
|
|
89
|
+
await doubled$.value;
|
|
90
|
+
expect(doubled$.staleValue).toBe(10);
|
|
91
|
+
|
|
92
|
+
count$.set(20);
|
|
93
|
+
// After recomputation
|
|
94
|
+
await doubled$.value;
|
|
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(({ get }) => get(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(({ get }) => get(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(({ get }) => get(count$) * 2, { fallback: 0 });
|
|
123
|
+
|
|
124
|
+
// Sync computation resolves immediately
|
|
125
|
+
await doubled$.value;
|
|
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(({ get }) => {
|
|
138
|
+
if (get(count$) > 3) {
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
return get(count$);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Wait for computation to complete
|
|
145
|
+
try {
|
|
146
|
+
await willThrow$.value;
|
|
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(({ get }) => get(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$.value;
|
|
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(({ get }) => {
|
|
192
|
+
callCount++;
|
|
193
|
+
return get(count$) * 2;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await doubled$.value;
|
|
197
|
+
expect(callCount).toBeGreaterThanOrEqual(1);
|
|
198
|
+
|
|
199
|
+
const countBefore = callCount;
|
|
200
|
+
doubled$.refresh();
|
|
201
|
+
await doubled$.value;
|
|
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(({ get }) => get(count$) * 2);
|
|
210
|
+
const listener = vi.fn();
|
|
211
|
+
|
|
212
|
+
await doubled$.value; // Initialize
|
|
213
|
+
doubled$.on(listener);
|
|
214
|
+
|
|
215
|
+
count$.set(10);
|
|
216
|
+
await doubled$.value; // 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(({ get }) => Math.min(get(count$), 10));
|
|
224
|
+
const listener = vi.fn();
|
|
225
|
+
|
|
226
|
+
await clamped$.value;
|
|
227
|
+
clamped$.on(listener);
|
|
228
|
+
|
|
229
|
+
// Value is already clamped to 10
|
|
230
|
+
count$.set(15); // Still clamps to 10
|
|
231
|
+
await clamped$.value;
|
|
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(({ get }) => get(count$) * 2);
|
|
240
|
+
const listener = vi.fn();
|
|
241
|
+
|
|
242
|
+
await doubled$.value;
|
|
243
|
+
const unsub = doubled$.on(listener);
|
|
244
|
+
|
|
245
|
+
count$.set(10);
|
|
246
|
+
await doubled$.value;
|
|
247
|
+
const callCount = listener.mock.calls.length;
|
|
248
|
+
|
|
249
|
+
unsub();
|
|
250
|
+
|
|
251
|
+
count$.set(20);
|
|
252
|
+
await doubled$.value;
|
|
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(({ get }) =>
|
|
266
|
+
get(showDetails$) ? get(details$) : get(summary$)
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const listener = vi.fn();
|
|
270
|
+
await content$.value;
|
|
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$.value).toBe("Brief");
|
|
278
|
+
|
|
279
|
+
showDetails$.set(true);
|
|
280
|
+
expect(await content$.value).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(({ get }) => {
|
|
288
|
+
const value = get(asyncValue$);
|
|
289
|
+
// At this point, get() 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$.value;
|
|
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(({ get }) => {
|
|
306
|
+
if (get(count$) > 10) {
|
|
307
|
+
throw new Error("Value too high");
|
|
308
|
+
}
|
|
309
|
+
return get(count$);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(await willThrow$.value).toBe(5);
|
|
313
|
+
|
|
314
|
+
count$.set(15);
|
|
315
|
+
await expect(willThrow$.value).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$.value).toBe(6);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("should support get() chaining", async () => {
|
|
334
|
+
const a$ = atom(2);
|
|
335
|
+
const b$ = atom(3);
|
|
336
|
+
|
|
337
|
+
const result$ = derived(({ get }) => {
|
|
338
|
+
const a = get(a$);
|
|
339
|
+
const b = get(b$);
|
|
340
|
+
return a * b;
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
expect(await result$.value).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(({ get }) => ({ ...get(source$) }));
|
|
351
|
+
const listener = vi.fn();
|
|
352
|
+
|
|
353
|
+
await derived$.value;
|
|
354
|
+
derived$.on(listener);
|
|
355
|
+
|
|
356
|
+
source$.set({ a: 1 }); // Same content, different reference
|
|
357
|
+
await derived$.value;
|
|
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(({ get }) => ({ ...get(source$) }), {
|
|
367
|
+
equals: "shallow",
|
|
368
|
+
});
|
|
369
|
+
const listener = vi.fn();
|
|
370
|
+
|
|
371
|
+
await derived$.value;
|
|
372
|
+
derived$.on(listener);
|
|
373
|
+
|
|
374
|
+
source$.set({ a: 1 }); // Same content
|
|
375
|
+
await derived$.value;
|
|
376
|
+
|
|
377
|
+
// With shallow equality, same content should not notify
|
|
378
|
+
// (depends on whether source triggers derived recomputation)
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
});
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { onCreateHook } from "./onCreateHook";
|
|
2
|
+
import { emitter } from "./emitter";
|
|
3
|
+
import { resolveEquality } from "./equality";
|
|
4
|
+
import { scheduleNotifyHook } from "./scheduleNotifyHook";
|
|
5
|
+
import { select, SelectContext } from "./select";
|
|
6
|
+
import {
|
|
7
|
+
Atom,
|
|
8
|
+
AtomState,
|
|
9
|
+
DerivedAtom,
|
|
10
|
+
DerivedOptions,
|
|
11
|
+
Equality,
|
|
12
|
+
SYMBOL_ATOM,
|
|
13
|
+
SYMBOL_DERIVED,
|
|
14
|
+
} from "./types";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Context object passed to derived atom selector functions.
|
|
18
|
+
* Provides utilities for reading atoms: `{ get, all, any, race, settled }`.
|
|
19
|
+
*
|
|
20
|
+
* Currently identical to `SelectContext`, but defined separately to allow
|
|
21
|
+
* future derived-specific extensions without breaking changes.
|
|
22
|
+
*/
|
|
23
|
+
export interface DerivedContext extends SelectContext {}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Creates a derived (computed) atom from source atom(s).
|
|
27
|
+
*
|
|
28
|
+
* Derived atoms are **read-only** and automatically recompute when their
|
|
29
|
+
* source atoms change. The `.value` property always returns a `Promise<T>`,
|
|
30
|
+
* even for synchronous computations.
|
|
31
|
+
*
|
|
32
|
+
* ## IMPORTANT: Selector Must Return Synchronous Value
|
|
33
|
+
*
|
|
34
|
+
* **The selector function MUST NOT be async or return a Promise.**
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* // ❌ WRONG - Don't use async function
|
|
38
|
+
* derived(async ({ get }) => {
|
|
39
|
+
* const data = await fetch('/api');
|
|
40
|
+
* return data;
|
|
41
|
+
* });
|
|
42
|
+
*
|
|
43
|
+
* // ❌ WRONG - Don't return a Promise
|
|
44
|
+
* derived(({ get }) => fetch('/api').then(r => r.json()));
|
|
45
|
+
*
|
|
46
|
+
* // ✅ CORRECT - Create async atom and read with get()
|
|
47
|
+
* const data$ = atom(fetch('/api').then(r => r.json()));
|
|
48
|
+
* derived(({ get }) => get(data$)); // Suspends until resolved
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* ## Key Features
|
|
52
|
+
*
|
|
53
|
+
* 1. **Always async**: `.value` returns `Promise<T>`
|
|
54
|
+
* 2. **Lazy computation**: Value is computed on first access
|
|
55
|
+
* 3. **Automatic updates**: Recomputes when any source atom changes
|
|
56
|
+
* 4. **Equality checking**: Only notifies if derived value changed
|
|
57
|
+
* 5. **Fallback support**: Optional fallback for loading/error states
|
|
58
|
+
* 6. **Suspense-like async**: `get()` throws promise if loading
|
|
59
|
+
* 7. **Conditional dependencies**: Only subscribes to atoms accessed
|
|
60
|
+
*
|
|
61
|
+
* ## Suspense-Style get()
|
|
62
|
+
*
|
|
63
|
+
* The `get()` function behaves like React Suspense:
|
|
64
|
+
* - If source atom is **loading**: `get()` throws the promise
|
|
65
|
+
* - If source atom has **error**: `get()` throws the error
|
|
66
|
+
* - If source atom has **value**: `get()` returns the value
|
|
67
|
+
*
|
|
68
|
+
* @template T - Derived value type
|
|
69
|
+
* @template F - Whether fallback is provided
|
|
70
|
+
* @param fn - Context-based derivation function (must return sync value, not Promise)
|
|
71
|
+
* @param options - Optional configuration (meta, equals, fallback)
|
|
72
|
+
* @returns A read-only derived atom
|
|
73
|
+
* @throws Error if selector returns a Promise or PromiseLike
|
|
74
|
+
*
|
|
75
|
+
* @example Basic derived (no fallback)
|
|
76
|
+
* ```ts
|
|
77
|
+
* const count$ = atom(5);
|
|
78
|
+
* const doubled$ = derived(({ get }) => get(count$) * 2);
|
|
79
|
+
*
|
|
80
|
+
* await doubled$.value; // 10
|
|
81
|
+
* doubled$.staleValue; // undefined (until first resolve) -> 10
|
|
82
|
+
* doubled$.state(); // { status: "ready", value: 10 }
|
|
83
|
+
* ```
|
|
84
|
+
*
|
|
85
|
+
* @example With fallback
|
|
86
|
+
* ```ts
|
|
87
|
+
* const posts$ = atom(fetchPosts());
|
|
88
|
+
* const count$ = derived(({ get }) => get(posts$).length, { fallback: 0 });
|
|
89
|
+
*
|
|
90
|
+
* count$.staleValue; // 0 (during loading) -> 42 (after resolve)
|
|
91
|
+
* count$.state(); // { status: "loading", promise } during loading
|
|
92
|
+
* // { status: "ready", value: 42 } after resolve
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* @example Async dependencies
|
|
96
|
+
* ```ts
|
|
97
|
+
* const user$ = atom(fetchUser());
|
|
98
|
+
* const posts$ = atom(fetchPosts());
|
|
99
|
+
*
|
|
100
|
+
* const dashboard$ = derived(({ all }) => {
|
|
101
|
+
* const [user, posts] = all(user$, posts$);
|
|
102
|
+
* return { user, posts };
|
|
103
|
+
* });
|
|
104
|
+
* ```
|
|
105
|
+
*
|
|
106
|
+
* @example Refresh
|
|
107
|
+
* ```ts
|
|
108
|
+
* const data$ = derived(({ get }) => get(source$));
|
|
109
|
+
* data$.refresh(); // Re-run computation
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
// Overload: Without fallback - staleValue is T | undefined
|
|
114
|
+
export function derived<T>(
|
|
115
|
+
fn: (ctx: DerivedContext) => T,
|
|
116
|
+
options?: DerivedOptions<T>
|
|
117
|
+
): DerivedAtom<T, false>;
|
|
118
|
+
|
|
119
|
+
// Overload: With fallback - staleValue is guaranteed T
|
|
120
|
+
export function derived<T>(
|
|
121
|
+
fn: (ctx: DerivedContext) => T,
|
|
122
|
+
options: DerivedOptions<T> & { fallback: T }
|
|
123
|
+
): DerivedAtom<T, true>;
|
|
124
|
+
|
|
125
|
+
// Implementation
|
|
126
|
+
export function derived<T>(
|
|
127
|
+
fn: (ctx: DerivedContext) => T,
|
|
128
|
+
options: DerivedOptions<T> & { fallback?: T } = {}
|
|
129
|
+
): DerivedAtom<T, boolean> {
|
|
130
|
+
const changeEmitter = emitter();
|
|
131
|
+
const eq = resolveEquality(options.equals as Equality<unknown>);
|
|
132
|
+
|
|
133
|
+
// Fallback configuration
|
|
134
|
+
const hasFallback = "fallback" in options;
|
|
135
|
+
const fallbackValue = options.fallback as T;
|
|
136
|
+
|
|
137
|
+
// State
|
|
138
|
+
let lastResolved: { value: T } | undefined;
|
|
139
|
+
let lastError: unknown = undefined;
|
|
140
|
+
let currentPromise: Promise<T> | null = null;
|
|
141
|
+
let isInitialized = false;
|
|
142
|
+
let isLoading = false;
|
|
143
|
+
let version = 0;
|
|
144
|
+
|
|
145
|
+
// Track current subscriptions (atom -> unsubscribe function)
|
|
146
|
+
const subscriptions = new Map<Atom<unknown>, VoidFunction>();
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Schedules notification to all subscribers.
|
|
150
|
+
*/
|
|
151
|
+
const notify = () => {
|
|
152
|
+
changeEmitter.forEach((listener) => {
|
|
153
|
+
scheduleNotifyHook.current(listener);
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Updates subscriptions based on new dependencies.
|
|
159
|
+
*/
|
|
160
|
+
const updateSubscriptions = (newDeps: Set<Atom<unknown>>) => {
|
|
161
|
+
// Unsubscribe from atoms that are no longer accessed
|
|
162
|
+
for (const [atom, unsubscribe] of subscriptions) {
|
|
163
|
+
if (!newDeps.has(atom)) {
|
|
164
|
+
unsubscribe();
|
|
165
|
+
subscriptions.delete(atom);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Subscribe to newly accessed atoms
|
|
170
|
+
for (const atom of newDeps) {
|
|
171
|
+
if (!subscriptions.has(atom)) {
|
|
172
|
+
const unsubscribe = atom.on(() => {
|
|
173
|
+
compute();
|
|
174
|
+
});
|
|
175
|
+
subscriptions.set(atom, unsubscribe);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Computes the derived value.
|
|
182
|
+
* Creates a new Promise that resolves when the computation completes.
|
|
183
|
+
*/
|
|
184
|
+
const compute = (silent = false) => {
|
|
185
|
+
const computeVersion = ++version;
|
|
186
|
+
isLoading = true;
|
|
187
|
+
lastError = undefined; // Clear error when starting new computation
|
|
188
|
+
|
|
189
|
+
// Create a new promise for this computation
|
|
190
|
+
currentPromise = new Promise<T>((resolve, reject) => {
|
|
191
|
+
// Run select to compute value and track dependencies
|
|
192
|
+
const attemptCompute = () => {
|
|
193
|
+
const result = select(fn);
|
|
194
|
+
|
|
195
|
+
// Update subscriptions based on accessed deps
|
|
196
|
+
updateSubscriptions(result.dependencies);
|
|
197
|
+
|
|
198
|
+
if (result.promise) {
|
|
199
|
+
// Promise thrown - wait for it and retry
|
|
200
|
+
result.promise.then(
|
|
201
|
+
() => {
|
|
202
|
+
// Check if we're still the current computation
|
|
203
|
+
if (version !== computeVersion) return;
|
|
204
|
+
attemptCompute();
|
|
205
|
+
},
|
|
206
|
+
(error) => {
|
|
207
|
+
// Check if we're still the current computation
|
|
208
|
+
if (version !== computeVersion) return;
|
|
209
|
+
isLoading = false;
|
|
210
|
+
lastError = error;
|
|
211
|
+
reject(error);
|
|
212
|
+
if (!silent) notify();
|
|
213
|
+
}
|
|
214
|
+
);
|
|
215
|
+
} else if (result.error !== undefined) {
|
|
216
|
+
// Error thrown
|
|
217
|
+
isLoading = false;
|
|
218
|
+
lastError = result.error;
|
|
219
|
+
reject(result.error);
|
|
220
|
+
if (!silent) notify();
|
|
221
|
+
} else {
|
|
222
|
+
// Success - update lastResolved and resolve
|
|
223
|
+
const newValue = result.value as T;
|
|
224
|
+
isLoading = false;
|
|
225
|
+
lastError = undefined;
|
|
226
|
+
|
|
227
|
+
// Only update and notify if value changed
|
|
228
|
+
if (!lastResolved || !eq(newValue, lastResolved.value)) {
|
|
229
|
+
lastResolved = { value: newValue };
|
|
230
|
+
if (!silent) notify();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
resolve(newValue);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
attemptCompute();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
return currentPromise;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Initializes the derived atom.
|
|
245
|
+
* Called lazily on first access.
|
|
246
|
+
*/
|
|
247
|
+
const init = () => {
|
|
248
|
+
if (isInitialized) return;
|
|
249
|
+
isInitialized = true;
|
|
250
|
+
|
|
251
|
+
// Initial computation (silent - don't notify on init)
|
|
252
|
+
compute(true);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const derivedAtom: DerivedAtom<T, boolean> = {
|
|
256
|
+
[SYMBOL_ATOM]: true as const,
|
|
257
|
+
[SYMBOL_DERIVED]: true as const,
|
|
258
|
+
meta: options.meta,
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The computed value as a Promise.
|
|
262
|
+
* Always returns Promise<T>, even for sync computations.
|
|
263
|
+
*/
|
|
264
|
+
get value(): Promise<T> {
|
|
265
|
+
init();
|
|
266
|
+
return currentPromise!;
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The stale value - fallback or last resolved value.
|
|
271
|
+
* - Without fallback: T | undefined
|
|
272
|
+
* - With fallback: T (guaranteed)
|
|
273
|
+
*/
|
|
274
|
+
get staleValue(): T | undefined {
|
|
275
|
+
init();
|
|
276
|
+
// Return lastResolvedValue if available, otherwise fallback (if configured)
|
|
277
|
+
if (lastResolved) {
|
|
278
|
+
return lastResolved.value;
|
|
279
|
+
}
|
|
280
|
+
if (hasFallback) {
|
|
281
|
+
return fallbackValue;
|
|
282
|
+
}
|
|
283
|
+
return undefined;
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Get the current state of the derived atom.
|
|
288
|
+
* Returns the actual underlying state (loading/ready/error).
|
|
289
|
+
* Use staleValue if you need fallback/cached value during loading.
|
|
290
|
+
*/
|
|
291
|
+
state(): AtomState<T> {
|
|
292
|
+
init();
|
|
293
|
+
|
|
294
|
+
if (isLoading) {
|
|
295
|
+
return { status: "loading", promise: currentPromise! };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (lastError !== undefined) {
|
|
299
|
+
return { status: "error", error: lastError };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (lastResolved) {
|
|
303
|
+
return { status: "ready", value: lastResolved.value };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Initial state before first computation completes
|
|
307
|
+
return { status: "loading", promise: currentPromise! };
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Re-run the computation.
|
|
312
|
+
*/
|
|
313
|
+
refresh(): void {
|
|
314
|
+
if (!isInitialized) {
|
|
315
|
+
init();
|
|
316
|
+
} else {
|
|
317
|
+
compute();
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Subscribe to value changes.
|
|
323
|
+
*/
|
|
324
|
+
on(listener: VoidFunction): VoidFunction {
|
|
325
|
+
init();
|
|
326
|
+
return changeEmitter.on(listener);
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// Notify devtools/plugins of derived atom creation
|
|
331
|
+
onCreateHook.current?.({
|
|
332
|
+
type: "derived",
|
|
333
|
+
key: options.meta?.key,
|
|
334
|
+
meta: options.meta,
|
|
335
|
+
atom: derivedAtom,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
return derivedAtom;
|
|
339
|
+
}
|