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,342 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { define } from "./define";
|
|
3
|
+
import { onCreateHook } from "./onCreateHook";
|
|
4
|
+
|
|
5
|
+
describe("define", () => {
|
|
6
|
+
const originalOnCreateHook = onCreateHook.current;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
onCreateHook.current = undefined;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
onCreateHook.current = originalOnCreateHook;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("basic functionality", () => {
|
|
17
|
+
it("should create a lazy singleton", () => {
|
|
18
|
+
const creator = vi.fn(() => ({ value: 42 }));
|
|
19
|
+
const store = define(creator);
|
|
20
|
+
|
|
21
|
+
// Creator not called yet
|
|
22
|
+
expect(creator).not.toHaveBeenCalled();
|
|
23
|
+
|
|
24
|
+
// First access creates instance
|
|
25
|
+
const instance1 = store();
|
|
26
|
+
expect(creator).toHaveBeenCalledTimes(1);
|
|
27
|
+
expect(instance1.value).toBe(42);
|
|
28
|
+
|
|
29
|
+
// Second access returns same instance
|
|
30
|
+
const instance2 = store();
|
|
31
|
+
expect(creator).toHaveBeenCalledTimes(1);
|
|
32
|
+
expect(instance2).toBe(instance1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should store key from options", () => {
|
|
36
|
+
const store = define(() => ({}), { key: "myStore" });
|
|
37
|
+
expect(store.key).toBe("myStore");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should have undefined key when not provided", () => {
|
|
41
|
+
const store = define(() => ({}));
|
|
42
|
+
expect(store.key).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("isInitialized", () => {
|
|
47
|
+
it("should return false before first access", () => {
|
|
48
|
+
const store = define(() => ({ value: 1 }));
|
|
49
|
+
expect(store.isInitialized()).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should return true after first access", () => {
|
|
53
|
+
const store = define(() => ({ value: 1 }));
|
|
54
|
+
store();
|
|
55
|
+
expect(store.isInitialized()).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("override", () => {
|
|
60
|
+
it("should allow overriding before initialization", () => {
|
|
61
|
+
const store = define(() => ({ value: "original" }));
|
|
62
|
+
|
|
63
|
+
store.override(() => ({ value: "overridden" }));
|
|
64
|
+
|
|
65
|
+
expect(store().value).toBe("overridden");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should throw if override called after initialization", () => {
|
|
69
|
+
const store = define(() => ({ value: "original" }));
|
|
70
|
+
|
|
71
|
+
// Initialize
|
|
72
|
+
store();
|
|
73
|
+
|
|
74
|
+
expect(() => {
|
|
75
|
+
store.override(() => ({ value: "overridden" }));
|
|
76
|
+
}).toThrow(
|
|
77
|
+
"Cannot override after initialization. Call override() before accessing the service."
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should provide original factory to override function", () => {
|
|
82
|
+
const store = define(() => ({ value: 1, extra: false }));
|
|
83
|
+
|
|
84
|
+
store.override((original) => ({
|
|
85
|
+
...original(),
|
|
86
|
+
extra: true,
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
const instance = store();
|
|
90
|
+
expect(instance.value).toBe(1);
|
|
91
|
+
expect(instance.extra).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should allow wrapping original behavior", () => {
|
|
95
|
+
const store = define(() => ({
|
|
96
|
+
getValue: () => 10 as number,
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
store.override((original) => {
|
|
100
|
+
const base = original();
|
|
101
|
+
return {
|
|
102
|
+
getValue: () => base.getValue() * 2,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(store().getValue()).toBe(20);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("isOverridden", () => {
|
|
111
|
+
it("should return false when not overridden", () => {
|
|
112
|
+
const store = define(() => ({}));
|
|
113
|
+
expect(store.isOverridden()).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("should return true when overridden", () => {
|
|
117
|
+
const store = define(() => ({}));
|
|
118
|
+
store.override(() => ({}));
|
|
119
|
+
expect(store.isOverridden()).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should return false after reset", () => {
|
|
123
|
+
const store = define(() => ({}));
|
|
124
|
+
store.override(() => ({}));
|
|
125
|
+
store.reset();
|
|
126
|
+
expect(store.isOverridden()).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("reset", () => {
|
|
131
|
+
it("should clear override and instance", () => {
|
|
132
|
+
const creator = vi.fn(() => ({ value: "original" }));
|
|
133
|
+
const store = define(creator);
|
|
134
|
+
|
|
135
|
+
store.override(() => ({ value: "overridden" }));
|
|
136
|
+
expect(store().value).toBe("overridden");
|
|
137
|
+
|
|
138
|
+
store.reset();
|
|
139
|
+
|
|
140
|
+
// Override cleared, original creator used
|
|
141
|
+
expect(store().value).toBe("original");
|
|
142
|
+
expect(store.isOverridden()).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("should call dispose if present", () => {
|
|
146
|
+
const dispose = vi.fn();
|
|
147
|
+
const store = define(() => ({
|
|
148
|
+
value: 1,
|
|
149
|
+
dispose,
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
store(); // Initialize
|
|
153
|
+
store.reset();
|
|
154
|
+
|
|
155
|
+
expect(dispose).toHaveBeenCalledTimes(1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("should allow re-initialization after reset", () => {
|
|
159
|
+
const creator = vi.fn(() => ({ value: Math.random() }));
|
|
160
|
+
const store = define(creator);
|
|
161
|
+
|
|
162
|
+
const first = store();
|
|
163
|
+
store.reset();
|
|
164
|
+
const second = store();
|
|
165
|
+
|
|
166
|
+
expect(first).not.toBe(second);
|
|
167
|
+
expect(creator).toHaveBeenCalledTimes(2);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("invalidate", () => {
|
|
172
|
+
it("should clear instance and allow re-creation", () => {
|
|
173
|
+
let counter = 0;
|
|
174
|
+
const store = define(() => ({ id: ++counter }));
|
|
175
|
+
|
|
176
|
+
expect(store().id).toBe(1);
|
|
177
|
+
expect(store().id).toBe(1); // Same instance
|
|
178
|
+
|
|
179
|
+
store.invalidate();
|
|
180
|
+
|
|
181
|
+
expect(store().id).toBe(2); // New instance
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("should call dispose if present", () => {
|
|
185
|
+
const dispose = vi.fn();
|
|
186
|
+
const store = define(() => ({
|
|
187
|
+
value: 1,
|
|
188
|
+
dispose,
|
|
189
|
+
}));
|
|
190
|
+
|
|
191
|
+
store(); // Initialize
|
|
192
|
+
store.invalidate();
|
|
193
|
+
|
|
194
|
+
expect(dispose).toHaveBeenCalledTimes(1);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("should also clear override", () => {
|
|
198
|
+
const store = define(() => ({ value: "original" }));
|
|
199
|
+
|
|
200
|
+
store.override(() => ({ value: "overridden" }));
|
|
201
|
+
expect(store().value).toBe("overridden");
|
|
202
|
+
|
|
203
|
+
store.invalidate();
|
|
204
|
+
|
|
205
|
+
expect(store.isOverridden()).toBe(false);
|
|
206
|
+
expect(store().value).toBe("original");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("dispose handling", () => {
|
|
211
|
+
it("should not throw if dispose is not a function", () => {
|
|
212
|
+
const store = define(() => ({
|
|
213
|
+
value: 1,
|
|
214
|
+
dispose: "not a function",
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
store();
|
|
218
|
+
|
|
219
|
+
expect(() => store.reset()).not.toThrow();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("should not throw if instance has no dispose", () => {
|
|
223
|
+
const store = define(() => ({ value: 1 }));
|
|
224
|
+
|
|
225
|
+
store();
|
|
226
|
+
|
|
227
|
+
expect(() => store.reset()).not.toThrow();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("should handle null instance gracefully", () => {
|
|
231
|
+
const store = define(() => ({ value: 1 }));
|
|
232
|
+
|
|
233
|
+
// Reset without initialization
|
|
234
|
+
expect(() => store.reset()).not.toThrow();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("onCreateHook", () => {
|
|
239
|
+
it("should call onCreateHook when module is created", () => {
|
|
240
|
+
const hookFn = vi.fn();
|
|
241
|
+
onCreateHook.current = hookFn;
|
|
242
|
+
|
|
243
|
+
const store = define(() => ({ value: 42 }), { key: "testModule" });
|
|
244
|
+
const instance = store();
|
|
245
|
+
|
|
246
|
+
expect(hookFn).toHaveBeenCalledTimes(1);
|
|
247
|
+
expect(hookFn).toHaveBeenCalledWith({
|
|
248
|
+
type: "module",
|
|
249
|
+
key: "testModule",
|
|
250
|
+
module: instance,
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("should call onCreateHook with undefined key when not provided", () => {
|
|
255
|
+
const hookFn = vi.fn();
|
|
256
|
+
onCreateHook.current = hookFn;
|
|
257
|
+
|
|
258
|
+
const store = define(() => ({ value: 42 }));
|
|
259
|
+
store();
|
|
260
|
+
|
|
261
|
+
expect(hookFn).toHaveBeenCalledWith({
|
|
262
|
+
type: "module",
|
|
263
|
+
key: undefined,
|
|
264
|
+
module: expect.any(Object),
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("should not throw when onCreateHook is undefined", () => {
|
|
269
|
+
onCreateHook.current = undefined;
|
|
270
|
+
|
|
271
|
+
const store = define(() => ({ value: 42 }));
|
|
272
|
+
expect(() => store()).not.toThrow();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("should call onCreateHook for overridden module", () => {
|
|
276
|
+
const hookFn = vi.fn();
|
|
277
|
+
onCreateHook.current = hookFn;
|
|
278
|
+
|
|
279
|
+
const store = define(() => ({ value: "original" }));
|
|
280
|
+
store.override(() => ({ value: "overridden" }));
|
|
281
|
+
const instance = store();
|
|
282
|
+
|
|
283
|
+
expect(hookFn).toHaveBeenCalledWith({
|
|
284
|
+
type: "module",
|
|
285
|
+
key: undefined,
|
|
286
|
+
module: instance,
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe("real-world patterns", () => {
|
|
292
|
+
it("should work as a service container", () => {
|
|
293
|
+
const apiService = define(() => ({
|
|
294
|
+
baseUrl: "https://api.example.com",
|
|
295
|
+
fetch: (endpoint: string) => `${endpoint}`,
|
|
296
|
+
}));
|
|
297
|
+
|
|
298
|
+
const userService = define(() => ({
|
|
299
|
+
api: apiService(),
|
|
300
|
+
getUser: (id: number) => `user-${id}`,
|
|
301
|
+
}));
|
|
302
|
+
|
|
303
|
+
expect(userService().api.baseUrl).toBe("https://api.example.com");
|
|
304
|
+
expect(userService().getUser(1)).toBe("user-1");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("should support testing with mocks", () => {
|
|
308
|
+
const apiService = define(() => ({
|
|
309
|
+
fetch: (url: string) => `real-${url}`,
|
|
310
|
+
}));
|
|
311
|
+
|
|
312
|
+
// In test setup
|
|
313
|
+
apiService.override(() => ({
|
|
314
|
+
fetch: vi.fn((url: string) => `mock-${url}`),
|
|
315
|
+
}));
|
|
316
|
+
|
|
317
|
+
expect(apiService().fetch("/users")).toBe("mock-/users");
|
|
318
|
+
|
|
319
|
+
// Cleanup
|
|
320
|
+
apiService.reset();
|
|
321
|
+
expect(apiService().fetch("/users")).toBe("real-/users");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("should support platform-specific implementations", () => {
|
|
325
|
+
const storageService = define(() => ({
|
|
326
|
+
get: (key: string) => `localStorage-${key}`,
|
|
327
|
+
set: (_key: string, _value: string) => {},
|
|
328
|
+
}));
|
|
329
|
+
|
|
330
|
+
// Simulate mobile platform override
|
|
331
|
+
const isMobile = true;
|
|
332
|
+
if (isMobile) {
|
|
333
|
+
storageService.override(() => ({
|
|
334
|
+
get: (key: string) => `secureStorage-${key}`,
|
|
335
|
+
set: (_key: string, _value: string) => {},
|
|
336
|
+
}));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
expect(storageService().get("token")).toBe("secureStorage-token");
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { onCreateHook } from "./onCreateHook";
|
|
2
|
+
import { ModuleMeta } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A factory function that creates a swappable lazy singleton store.
|
|
6
|
+
*
|
|
7
|
+
* @template TModule The type of the store instance
|
|
8
|
+
*/
|
|
9
|
+
export interface Define<TModule> {
|
|
10
|
+
readonly key: string | undefined;
|
|
11
|
+
/** Get the current service instance (creates lazily on first call) */
|
|
12
|
+
(): TModule;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Override the service implementation with a lazy factory.
|
|
16
|
+
* Useful for testing, platform-specific implementations, or feature flags.
|
|
17
|
+
* The factory is called lazily on first access after override.
|
|
18
|
+
*
|
|
19
|
+
* **IMPORTANT**: Must be called **before** the service is initialized.
|
|
20
|
+
* Throws an error if called after the service has been accessed.
|
|
21
|
+
*
|
|
22
|
+
* @param factory - Factory function that creates the replacement implementation.
|
|
23
|
+
* Receives the original factory as argument for extending.
|
|
24
|
+
*
|
|
25
|
+
* @throws {Error} If called after the service has been initialized
|
|
26
|
+
*
|
|
27
|
+
* @example Full replacement
|
|
28
|
+
* ```ts
|
|
29
|
+
* myService.override(() => ({ value: 'mock' }));
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @example Extend original
|
|
33
|
+
* ```ts
|
|
34
|
+
* myService.override((original) => ({
|
|
35
|
+
* ...original(),
|
|
36
|
+
* extraMethod() { ... }
|
|
37
|
+
* }));
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
override(factory: (original: () => TModule) => TModule): void;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reset to the original implementation.
|
|
44
|
+
* Clears any override set via `.override()` and disposes the current instance.
|
|
45
|
+
* Next access will create a fresh original instance.
|
|
46
|
+
*/
|
|
47
|
+
reset(): void;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Invalidate the cached instance. Next call will create a fresh instance.
|
|
51
|
+
* If the current instance has a `dispose()` method, it will be called before clearing.
|
|
52
|
+
*
|
|
53
|
+
* Unlike `reset()` which only clears overrides, `invalidate()` clears everything
|
|
54
|
+
* so the next access creates a completely fresh instance from the factory.
|
|
55
|
+
*/
|
|
56
|
+
invalidate(): void;
|
|
57
|
+
|
|
58
|
+
/** Returns true if currently using an overridden implementation via `.override()` */
|
|
59
|
+
isOverridden(): boolean;
|
|
60
|
+
|
|
61
|
+
/** Returns true if the lazy instance has been created */
|
|
62
|
+
isInitialized(): boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface DefineOptions {
|
|
66
|
+
key?: string;
|
|
67
|
+
meta?: ModuleMeta;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Creates a swappable lazy singleton store.
|
|
72
|
+
*
|
|
73
|
+
* Unlike `once()` from lodash, `define()` allows you to:
|
|
74
|
+
* - Override the implementation at runtime with `.override()`
|
|
75
|
+
* - Reset to the original with `.reset()`
|
|
76
|
+
* - Invalidate and recreate fresh with `.invalidate()`
|
|
77
|
+
*
|
|
78
|
+
* This is useful for:
|
|
79
|
+
* - **Testing** - inject mocks without module mocking
|
|
80
|
+
* - **Platform-specific** - mobile vs web implementations
|
|
81
|
+
* - **Feature flags** - swap implementations at runtime
|
|
82
|
+
*
|
|
83
|
+
* @param creator - Factory function that creates the store instance
|
|
84
|
+
* @returns A callable store with `.override()`, `.reset()`, and `.invalidate()` methods
|
|
85
|
+
*
|
|
86
|
+
* @example Basic usage
|
|
87
|
+
* ```ts
|
|
88
|
+
* const counterStore = define(() => {
|
|
89
|
+
* const [count, setCount] = atom(0);
|
|
90
|
+
* return {
|
|
91
|
+
* count,
|
|
92
|
+
* increment: () => setCount((c) => c + 1),
|
|
93
|
+
* };
|
|
94
|
+
* });
|
|
95
|
+
*
|
|
96
|
+
* // Normal usage - lazy singleton
|
|
97
|
+
* const store = counterStore();
|
|
98
|
+
* store.increment();
|
|
99
|
+
* ```
|
|
100
|
+
*
|
|
101
|
+
* @example Platform-specific implementation
|
|
102
|
+
* ```ts
|
|
103
|
+
* const storageStore = define(() => ({
|
|
104
|
+
* get: (key) => localStorage.getItem(key),
|
|
105
|
+
* set: (key, value) => localStorage.setItem(key, value),
|
|
106
|
+
* }));
|
|
107
|
+
*
|
|
108
|
+
* // On mobile, swap to secure storage BEFORE first access
|
|
109
|
+
* if (isMobile()) {
|
|
110
|
+
* storageStore.override(() => ({
|
|
111
|
+
* get: (key) => SecureStore.getItem(key),
|
|
112
|
+
* set: (key, value) => SecureStore.setItem(key, value),
|
|
113
|
+
* }));
|
|
114
|
+
* }
|
|
115
|
+
* ```
|
|
116
|
+
*
|
|
117
|
+
* @example Extending original with extra methods
|
|
118
|
+
* ```ts
|
|
119
|
+
* apiStore.override((original) => ({
|
|
120
|
+
* ...original(),
|
|
121
|
+
* mockFetch: vi.fn(),
|
|
122
|
+
* }));
|
|
123
|
+
* ```
|
|
124
|
+
*
|
|
125
|
+
* @example Wrapping original behavior
|
|
126
|
+
* ```ts
|
|
127
|
+
* loggerStore.override((original) => {
|
|
128
|
+
* const base = original();
|
|
129
|
+
* return {
|
|
130
|
+
* ...base,
|
|
131
|
+
* log: (msg) => {
|
|
132
|
+
* console.log('[DEBUG]', msg);
|
|
133
|
+
* base.log(msg);
|
|
134
|
+
* },
|
|
135
|
+
* };
|
|
136
|
+
* });
|
|
137
|
+
* ```
|
|
138
|
+
*
|
|
139
|
+
* @example Testing with reset (creates fresh instances)
|
|
140
|
+
* ```ts
|
|
141
|
+
* beforeEach(() => {
|
|
142
|
+
* counterStore.override(() => ({
|
|
143
|
+
* count: () => 999,
|
|
144
|
+
* increment: vi.fn(),
|
|
145
|
+
* }));
|
|
146
|
+
* });
|
|
147
|
+
*
|
|
148
|
+
* afterEach(() => {
|
|
149
|
+
* counterStore.reset(); // Clears override, next call creates fresh original
|
|
150
|
+
* });
|
|
151
|
+
* ```
|
|
152
|
+
*
|
|
153
|
+
* @example Testing with invalidate (fresh instance each test)
|
|
154
|
+
* ```ts
|
|
155
|
+
* afterEach(() => {
|
|
156
|
+
* counterStore.invalidate(); // Next call creates fresh instance
|
|
157
|
+
* });
|
|
158
|
+
*
|
|
159
|
+
* it('test 1', () => {
|
|
160
|
+
* counterStore().increment(); // count = 1
|
|
161
|
+
* });
|
|
162
|
+
*
|
|
163
|
+
* it('test 2', () => {
|
|
164
|
+
* // Fresh instance, count starts at 0 again
|
|
165
|
+
* expect(counterStore().count()).toBe(0);
|
|
166
|
+
* });
|
|
167
|
+
* ```
|
|
168
|
+
*
|
|
169
|
+
* @example Store with dispose cleanup
|
|
170
|
+
* ```ts
|
|
171
|
+
* const connectionStore = define(() => {
|
|
172
|
+
* const connection = createConnection();
|
|
173
|
+
* return {
|
|
174
|
+
* query: (sql) => connection.query(sql),
|
|
175
|
+
* dispose: () => connection.close(), // Called on invalidate()
|
|
176
|
+
* };
|
|
177
|
+
* });
|
|
178
|
+
*
|
|
179
|
+
* connectionStore.invalidate(); // Closes connection, next call creates new
|
|
180
|
+
* ```
|
|
181
|
+
*/
|
|
182
|
+
export function define<T>(
|
|
183
|
+
creator: () => T,
|
|
184
|
+
options?: DefineOptions
|
|
185
|
+
): Define<T> {
|
|
186
|
+
let instance: T | undefined;
|
|
187
|
+
let overrideFactory: ((original: () => T) => T) | undefined;
|
|
188
|
+
|
|
189
|
+
const tryDispose = (target: T | undefined) => {
|
|
190
|
+
if (
|
|
191
|
+
target &&
|
|
192
|
+
typeof target === "object" &&
|
|
193
|
+
"dispose" in target &&
|
|
194
|
+
typeof target.dispose === "function"
|
|
195
|
+
) {
|
|
196
|
+
target.dispose();
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const clearInstance = () => {
|
|
201
|
+
tryDispose(instance);
|
|
202
|
+
instance = undefined;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const get = (): T => {
|
|
206
|
+
if (!instance) {
|
|
207
|
+
if (overrideFactory) {
|
|
208
|
+
instance = overrideFactory!(creator);
|
|
209
|
+
} else {
|
|
210
|
+
instance = creator();
|
|
211
|
+
}
|
|
212
|
+
onCreateHook.current?.({
|
|
213
|
+
type: "module",
|
|
214
|
+
key: options?.key,
|
|
215
|
+
meta: options?.meta,
|
|
216
|
+
module: instance,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return instance;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
return Object.assign(get, {
|
|
223
|
+
key: options?.key,
|
|
224
|
+
override: (factory: (original: () => T) => T) => {
|
|
225
|
+
if (instance !== undefined) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
"Cannot override after initialization. Call override() before accessing the service."
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
overrideFactory = factory;
|
|
231
|
+
},
|
|
232
|
+
reset: () => {
|
|
233
|
+
overrideFactory = undefined;
|
|
234
|
+
clearInstance();
|
|
235
|
+
},
|
|
236
|
+
invalidate: () => {
|
|
237
|
+
overrideFactory = undefined;
|
|
238
|
+
clearInstance();
|
|
239
|
+
},
|
|
240
|
+
isOverridden: () => overrideFactory !== undefined,
|
|
241
|
+
isInitialized: () => instance !== undefined,
|
|
242
|
+
});
|
|
243
|
+
}
|