mvc-kit 2.12.0 → 2.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/agent-config/bin/postinstall.mjs +5 -3
  2. package/agent-config/bin/setup.mjs +3 -4
  3. package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
  4. package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
  5. package/agent-config/lib/install-claude.mjs +10 -33
  6. package/dist/Model.cjs +9 -1
  7. package/dist/Model.cjs.map +1 -1
  8. package/dist/Model.d.ts +1 -1
  9. package/dist/Model.d.ts.map +1 -1
  10. package/dist/Model.js +9 -1
  11. package/dist/Model.js.map +1 -1
  12. package/dist/ViewModel.cjs +9 -1
  13. package/dist/ViewModel.cjs.map +1 -1
  14. package/dist/ViewModel.d.ts +1 -1
  15. package/dist/ViewModel.d.ts.map +1 -1
  16. package/dist/ViewModel.js +9 -1
  17. package/dist/ViewModel.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/mvc-kit.cjs +3 -0
  21. package/dist/mvc-kit.cjs.map +1 -1
  22. package/dist/mvc-kit.js +3 -0
  23. package/dist/mvc-kit.js.map +1 -1
  24. package/dist/produceDraft.cjs +105 -0
  25. package/dist/produceDraft.cjs.map +1 -0
  26. package/dist/produceDraft.d.ts +19 -0
  27. package/dist/produceDraft.d.ts.map +1 -0
  28. package/dist/produceDraft.js +105 -0
  29. package/dist/produceDraft.js.map +1 -0
  30. package/package.json +4 -2
  31. package/src/Channel.md +408 -0
  32. package/src/Channel.test.ts +957 -0
  33. package/src/Channel.ts +429 -0
  34. package/src/Collection.md +533 -0
  35. package/src/Collection.test.ts +1559 -0
  36. package/src/Collection.ts +653 -0
  37. package/src/Controller.md +306 -0
  38. package/src/Controller.test.ts +380 -0
  39. package/src/Controller.ts +90 -0
  40. package/src/EventBus.md +308 -0
  41. package/src/EventBus.test.ts +295 -0
  42. package/src/EventBus.ts +110 -0
  43. package/src/Feed.md +218 -0
  44. package/src/Feed.test.ts +442 -0
  45. package/src/Feed.ts +101 -0
  46. package/src/Model.md +524 -0
  47. package/src/Model.test.ts +642 -0
  48. package/src/Model.ts +260 -0
  49. package/src/Pagination.md +168 -0
  50. package/src/Pagination.test.ts +244 -0
  51. package/src/Pagination.ts +92 -0
  52. package/src/Pending.md +380 -0
  53. package/src/Pending.test.ts +1719 -0
  54. package/src/Pending.ts +390 -0
  55. package/src/PersistentCollection.md +183 -0
  56. package/src/PersistentCollection.test.ts +649 -0
  57. package/src/PersistentCollection.ts +375 -0
  58. package/src/Resource.ViewModel.test.ts +503 -0
  59. package/src/Resource.md +239 -0
  60. package/src/Resource.test.ts +786 -0
  61. package/src/Resource.ts +231 -0
  62. package/src/Selection.md +155 -0
  63. package/src/Selection.test.ts +326 -0
  64. package/src/Selection.ts +117 -0
  65. package/src/Service.md +440 -0
  66. package/src/Service.test.ts +241 -0
  67. package/src/Service.ts +72 -0
  68. package/src/Sorting.md +170 -0
  69. package/src/Sorting.test.ts +334 -0
  70. package/src/Sorting.ts +135 -0
  71. package/src/Trackable.md +166 -0
  72. package/src/Trackable.test.ts +236 -0
  73. package/src/Trackable.ts +129 -0
  74. package/src/ViewModel.async.test.ts +813 -0
  75. package/src/ViewModel.derived.test.ts +1583 -0
  76. package/src/ViewModel.md +1111 -0
  77. package/src/ViewModel.test.ts +1236 -0
  78. package/src/ViewModel.ts +800 -0
  79. package/src/bindPublicMethods.test.ts +126 -0
  80. package/src/bindPublicMethods.ts +48 -0
  81. package/src/env.d.ts +5 -0
  82. package/src/errors.test.ts +155 -0
  83. package/src/errors.ts +133 -0
  84. package/src/index.ts +49 -0
  85. package/src/produceDraft.md +90 -0
  86. package/src/produceDraft.test.ts +394 -0
  87. package/src/produceDraft.ts +168 -0
  88. package/src/react/components/CardList.md +97 -0
  89. package/src/react/components/CardList.test.tsx +142 -0
  90. package/src/react/components/CardList.tsx +68 -0
  91. package/src/react/components/DataTable.md +179 -0
  92. package/src/react/components/DataTable.test.tsx +599 -0
  93. package/src/react/components/DataTable.tsx +267 -0
  94. package/src/react/components/InfiniteScroll.md +116 -0
  95. package/src/react/components/InfiniteScroll.test.tsx +218 -0
  96. package/src/react/components/InfiniteScroll.tsx +70 -0
  97. package/src/react/components/types.ts +90 -0
  98. package/src/react/derived.test.tsx +261 -0
  99. package/src/react/guards.ts +24 -0
  100. package/src/react/index.ts +40 -0
  101. package/src/react/provider.test.tsx +143 -0
  102. package/src/react/provider.tsx +55 -0
  103. package/src/react/strict-mode.test.tsx +266 -0
  104. package/src/react/types.ts +25 -0
  105. package/src/react/use-event-bus.md +214 -0
  106. package/src/react/use-event-bus.test.tsx +168 -0
  107. package/src/react/use-event-bus.ts +40 -0
  108. package/src/react/use-instance.md +204 -0
  109. package/src/react/use-instance.test.tsx +350 -0
  110. package/src/react/use-instance.ts +60 -0
  111. package/src/react/use-local.md +457 -0
  112. package/src/react/use-local.rapid-remount.test.tsx +503 -0
  113. package/src/react/use-local.test.tsx +692 -0
  114. package/src/react/use-local.ts +165 -0
  115. package/src/react/use-model.md +364 -0
  116. package/src/react/use-model.test.tsx +394 -0
  117. package/src/react/use-model.ts +161 -0
  118. package/src/react/use-singleton.md +415 -0
  119. package/src/react/use-singleton.test.tsx +296 -0
  120. package/src/react/use-singleton.ts +69 -0
  121. package/src/react/use-subscribe-only.ts +39 -0
  122. package/src/react/use-teardown.md +169 -0
  123. package/src/react/use-teardown.test.tsx +86 -0
  124. package/src/react/use-teardown.ts +27 -0
  125. package/src/react-native/NativeCollection.test.ts +250 -0
  126. package/src/react-native/NativeCollection.ts +138 -0
  127. package/src/react-native/index.ts +1 -0
  128. package/src/singleton.md +310 -0
  129. package/src/singleton.test.ts +204 -0
  130. package/src/singleton.ts +70 -0
  131. package/src/types.ts +70 -0
  132. package/src/walkPrototypeChain.ts +22 -0
  133. package/src/web/IndexedDBCollection.test.ts +235 -0
  134. package/src/web/IndexedDBCollection.ts +66 -0
  135. package/src/web/WebStorageCollection.test.ts +214 -0
  136. package/src/web/WebStorageCollection.ts +116 -0
  137. package/src/web/idb.ts +184 -0
  138. package/src/web/index.ts +2 -0
  139. package/src/wrapAsyncMethods.ts +249 -0
@@ -0,0 +1,138 @@
1
+ import { PersistentCollection } from '../PersistentCollection';
2
+
3
+ const __DEV__ = typeof __MVC_KIT_DEV__ !== 'undefined' && __MVC_KIT_DEV__;
4
+
5
+ interface StorageAdapter {
6
+ getItem(key: string): Promise<string | null>;
7
+ setItem(key: string, value: string): Promise<void>;
8
+ removeItem(key: string): Promise<void>;
9
+ }
10
+
11
+ let _adapter: StorageAdapter | null = null;
12
+
13
+ /**
14
+ * PersistentCollection for React Native, backed by any async key-value store.
15
+ * Uses blob strategy (full state as a single JSON string under `storageKey`).
16
+ *
17
+ * **Requires manual `hydrate()` call** (async storage).
18
+ *
19
+ * ## Setup (once at app startup)
20
+ *
21
+ * ```ts
22
+ * import { NativeCollection } from 'mvc-kit/react-native';
23
+ * import AsyncStorage from '@react-native-async-storage/async-storage';
24
+ *
25
+ * NativeCollection.configure({
26
+ * getItem: (key) => AsyncStorage.getItem(key),
27
+ * setItem: (key, value) => AsyncStorage.setItem(key, value),
28
+ * removeItem: (key) => AsyncStorage.removeItem(key),
29
+ * });
30
+ * ```
31
+ *
32
+ * ## Usage
33
+ *
34
+ * ```ts
35
+ * class TodosCollection extends NativeCollection<Todo> {
36
+ * protected readonly storageKey = 'todos';
37
+ * }
38
+ * ```
39
+ *
40
+ * ## Per-class override (edge cases)
41
+ *
42
+ * ```ts
43
+ * class SecureCollection extends NativeCollection<Secret> {
44
+ * protected readonly storageKey = 'secrets';
45
+ * protected async getItem(key: string) { return SecureStore.getItem(key); }
46
+ * protected async setItem(key: string, value: string) { await SecureStore.setItem(key, value); }
47
+ * protected async removeItem(key: string) { await SecureStore.removeItem(key); }
48
+ * }
49
+ * ```
50
+ */
51
+ export abstract class NativeCollection<
52
+ T extends { id: string | number },
53
+ > extends PersistentCollection<T> {
54
+ /**
55
+ * Configure the default storage adapter for all NativeCollection subclasses.
56
+ * Call once at app startup. Per-class method overrides take priority.
57
+ */
58
+ static configure(adapter: StorageAdapter): void {
59
+ _adapter = adapter;
60
+ }
61
+
62
+ /** Reset the configured adapter (for testing). */
63
+ static resetAdapter(): void {
64
+ _adapter = null;
65
+ }
66
+
67
+ // ── Per-class override points ──
68
+
69
+ /** Read a value from the storage adapter. Override for custom storage backends. @protected */
70
+ protected getItem(key: string): Promise<string | null> {
71
+ if (_adapter) return _adapter.getItem(key);
72
+ if (__DEV__) {
73
+ throw new Error(
74
+ `[mvc-kit] No storage adapter configured for "${this.constructor.name}". ` +
75
+ `Call NativeCollection.configure() at app startup, or override getItem/setItem/removeItem.`,
76
+ );
77
+ }
78
+ throw new Error('[mvc-kit] No storage adapter configured.');
79
+ }
80
+
81
+ /** Write a value to the storage adapter. Override for custom storage backends. @protected */
82
+ protected setItem(key: string, value: string): Promise<void> {
83
+ if (_adapter) return _adapter.setItem(key, value);
84
+ if (__DEV__) {
85
+ throw new Error(
86
+ `[mvc-kit] No storage adapter configured for "${this.constructor.name}". ` +
87
+ `Call NativeCollection.configure() at app startup, or override getItem/setItem/removeItem.`,
88
+ );
89
+ }
90
+ throw new Error('[mvc-kit] No storage adapter configured.');
91
+ }
92
+
93
+ /** Remove a value from the storage adapter. Override for custom storage backends. @protected */
94
+ protected removeItem(key: string): Promise<void> {
95
+ if (_adapter) return _adapter.removeItem(key);
96
+ if (__DEV__) {
97
+ throw new Error(
98
+ `[mvc-kit] No storage adapter configured for "${this.constructor.name}". ` +
99
+ `Call NativeCollection.configure() at app startup, or override getItem/setItem/removeItem.`,
100
+ );
101
+ }
102
+ throw new Error('[mvc-kit] No storage adapter configured.');
103
+ }
104
+
105
+ // ── Persist interface (blob strategy) ──
106
+
107
+ protected async persistGetAll(): Promise<T[]> {
108
+ const raw = await this.getItem(this.storageKey);
109
+ if (!raw) return [];
110
+ try {
111
+ return this.deserialize(raw);
112
+ } catch {
113
+ if (__DEV__) {
114
+ console.warn(
115
+ `[mvc-kit] Corrupted data in storage key "${this.storageKey}". Ignoring stored data.`,
116
+ );
117
+ }
118
+ return [];
119
+ }
120
+ }
121
+
122
+ protected async persistGet(id: T['id']): Promise<T | null> {
123
+ const all = await this.persistGetAll();
124
+ return all.find((i) => i.id === id) ?? null;
125
+ }
126
+
127
+ protected async persistSet(_items: T[]): Promise<void> {
128
+ await this.setItem(this.storageKey, this.serialize([...this.items]));
129
+ }
130
+
131
+ protected async persistRemove(_ids: T['id'][]): Promise<void> {
132
+ await this.setItem(this.storageKey, this.serialize([...this.items]));
133
+ }
134
+
135
+ protected async persistClear(): Promise<void> {
136
+ await this.removeItem(this.storageKey);
137
+ }
138
+ }
@@ -0,0 +1 @@
1
+ export { NativeCollection } from './NativeCollection';
@@ -0,0 +1,310 @@
1
+ # Singleton Registry
2
+
3
+ A global registry that ensures exactly one living instance of a class exists at any time. Used to share Services, Collections, EventBuses, and Channels across ViewModels without manual wiring.
4
+
5
+ ---
6
+
7
+ ## API
8
+
9
+ ### singleton(Class, ...args)
10
+
11
+ Returns the existing instance for `Class`, or creates one if none exists (or the previous one was disposed).
12
+
13
+ ```typescript
14
+ const service = singleton(UserService);
15
+ const collection = singleton(UsersCollection);
16
+ ```
17
+
18
+ Constructor arguments are passed on first creation only. Subsequent calls return the cached instance and **ignore any arguments**:
19
+
20
+ ```typescript
21
+ const vm1 = singleton(CounterViewModel, { count: 0 });
22
+ vm1.increment(); // count is now 1
23
+
24
+ const vm2 = singleton(CounterViewModel, { count: 100 }); // args ignored
25
+ vm2 === vm1; // true
26
+ vm2.state.count === 1; // true — same instance, same state
27
+ ```
28
+
29
+ ### Default State
30
+
31
+ If a class defines `static DEFAULT_STATE`, `singleton()` uses it as the constructor argument when no args are passed. This eliminates repeated object literals at every call site:
32
+
33
+ ```typescript
34
+ class CartViewModel extends ViewModel<CartState> {
35
+ static DEFAULT_STATE: CartState = { items: [] };
36
+
37
+ // ...methods
38
+ }
39
+
40
+ // No args needed — DEFAULT_STATE is used automatically
41
+ const cart = singleton(CartViewModel);
42
+ cart.state.items; // []
43
+ ```
44
+
45
+ Explicit args override `DEFAULT_STATE`:
46
+
47
+ ```typescript
48
+ const cart = singleton(CartViewModel, { items: [existingItem] }); // uses explicit arg
49
+ ```
50
+
51
+ This is especially useful for singleton ViewModels (auth, cart, theme) where every consumer previously had to repeat the same initial state object.
52
+
53
+ **Disposed instance replacement:** If the registered instance has been disposed (manually or via `teardown`), the next `singleton()` call creates a fresh instance:
54
+
55
+ ```typescript
56
+ const vm1 = singleton(CounterViewModel, { count: 0 });
57
+ vm1.dispose();
58
+
59
+ const vm2 = singleton(CounterViewModel, { count: 0 });
60
+ vm2 === vm1; // false — new instance
61
+ vm2.disposed === false; // true — fresh lifecycle
62
+ ```
63
+
64
+ ### hasSingleton(Class)
65
+
66
+ Returns `true` if a non-disposed instance exists in the registry for `Class`.
67
+
68
+ ```typescript
69
+ hasSingleton(UserService); // false
70
+ singleton(UserService);
71
+ hasSingleton(UserService); // true
72
+
73
+ teardown(UserService);
74
+ hasSingleton(UserService); // false
75
+ ```
76
+
77
+ Also returns `false` if the instance was manually disposed (without calling `teardown`):
78
+
79
+ ```typescript
80
+ const svc = singleton(UserService);
81
+ svc.dispose();
82
+ hasSingleton(UserService); // false
83
+ ```
84
+
85
+ ### teardown(Class)
86
+
87
+ Disposes the singleton instance for `Class` and removes it from the registry. Safe to call when no instance exists — it's a no-op.
88
+
89
+ ```typescript
90
+ const svc = singleton(UserService);
91
+ teardown(UserService);
92
+
93
+ svc.disposed === true; // true
94
+ hasSingleton(UserService) === false; // true
95
+ ```
96
+
97
+ After `teardown`, the next `singleton()` call creates a fresh instance with a fresh `disposeSignal`:
98
+
99
+ ```typescript
100
+ const vm1 = singleton(CounterViewModel, { count: 0 });
101
+ const signal1 = vm1.disposeSignal;
102
+
103
+ teardown(CounterViewModel);
104
+ signal1.aborted === true; // true — old signal aborted
105
+
106
+ const vm2 = singleton(CounterViewModel, { count: 0 });
107
+ vm2.disposeSignal.aborted === false; // true — fresh signal
108
+ vm2.disposeSignal !== signal1; // true — different object
109
+ ```
110
+
111
+ ### teardownAll()
112
+
113
+ Disposes every singleton in the registry and clears it. Safe to call on an empty registry.
114
+
115
+ ```typescript
116
+ const svc = singleton(UserService);
117
+ const col = singleton(UsersCollection);
118
+
119
+ teardownAll();
120
+
121
+ svc.disposed === true; // true
122
+ col.disposed === true; // true
123
+ ```
124
+
125
+ Primary use case is test cleanup:
126
+
127
+ ```typescript
128
+ beforeEach(() => {
129
+ teardownAll();
130
+ });
131
+ ```
132
+
133
+ ---
134
+
135
+ ## How It Works
136
+
137
+ The registry is a module-level `Map<Class, Instance>`. The class constructor itself is the key — each distinct class maps to at most one instance.
138
+
139
+ ```
140
+ singleton(UserService) → registry.get(UserService)
141
+ → exists and not disposed? return it
142
+ → otherwise: new UserService(), store in registry, return it
143
+ ```
144
+
145
+ Key behaviors:
146
+
147
+ - **Class identity is the key.** Two different classes that extend `Service` are separate entries. Subclasses are distinct from parents.
148
+ - **Disposed instances are treated as absent.** `singleton()` and `hasSingleton()` both check `instance.disposed` before returning or reporting.
149
+ - **Arguments are first-call-only.** The registry stores the instance, not the arguments. On cache hit, arguments are ignored entirely.
150
+ - **Any `Disposable` works.** The registry accepts any class whose instances implement the `Disposable` interface (`disposed`, `disposeSignal`, `dispose()`). This includes ViewModel, Service, Collection, EventBus, Channel, Controller, and Model.
151
+
152
+ ---
153
+
154
+ ## Which Classes Are Singletons
155
+
156
+ | Class | Singleton? | Why |
157
+ |---|---|---|
158
+ | Service | Yes | Stateless infrastructure — one instance is sufficient |
159
+ | Collection | Yes | Shared data cache — must be the same instance across ViewModels |
160
+ | EventBus (app-level) | Yes | App-wide event dispatch |
161
+ | Channel | Yes | Persistent connection shared across ViewModels |
162
+ | ViewModel | Rarely | Component-scoped by default; singleton only for app-wide state (auth, theme, cart) |
163
+ | Controller | No | Workflow-scoped, tied to a component lifecycle |
164
+ | Model | No | Form-scoped, tied to a specific edit session |
165
+
166
+ ---
167
+
168
+ ## Usage Patterns
169
+
170
+ ### Property Initializer (Recommended)
171
+
172
+ Resolve singletons as private fields in ViewModel property initializers. This is the standard pattern:
173
+
174
+ ```typescript
175
+ class LocationsViewModel extends ViewModel<State> {
176
+ private service = singleton(LocationService);
177
+ private collection = singleton(LocationsCollection);
178
+ private bus = singleton(AppEventBus);
179
+
180
+ protected onInit() {
181
+ this.subscribeTo(this.collection, () => {
182
+ this.set({ items: this.collection.items });
183
+ });
184
+ }
185
+ }
186
+ ```
187
+
188
+ Every ViewModel that calls `singleton(LocationsCollection)` gets the same instance. When one ViewModel writes data into the collection, all subscribers see the change.
189
+
190
+ ### Pre-Populating in Tests
191
+
192
+ In tests, call `singleton()` before constructing the ViewModel to pre-populate shared state:
193
+
194
+ ```typescript
195
+ beforeEach(() => teardownAll());
196
+
197
+ test('filtered getter applies search', () => {
198
+ const collection = singleton(UsersCollection);
199
+ collection.reset([
200
+ { id: '1', firstName: 'Alice', status: 'on_duty' },
201
+ { id: '2', firstName: 'Bob', status: 'off_duty' },
202
+ ]);
203
+
204
+ const vm = new UsersViewModel({ items: [], search: '' });
205
+ vm.init();
206
+
207
+ // onInit subscribes to the same collection instance
208
+ expect(vm.state.items).toHaveLength(2);
209
+
210
+ vm.dispose();
211
+ });
212
+ ```
213
+
214
+ The ViewModel's `singleton(UsersCollection)` call returns the same instance the test already populated.
215
+
216
+ ### Shared Data via Collection Singletons
217
+
218
+ Multiple ViewModels stay in sync through a shared collection without knowing about each other:
219
+
220
+ ```typescript
221
+ // UsersViewModel fetches and writes
222
+ async load() {
223
+ const data = await this.service.getAll(this.disposeSignal);
224
+ this.collection.reset(data); // triggers all subscribers
225
+ }
226
+
227
+ // OnDutyViewModel reads from the same collection
228
+ protected onInit() {
229
+ this.subscribeTo(this.collection, () => {
230
+ this.set({ items: this.collection.items });
231
+ });
232
+ }
233
+ ```
234
+
235
+ Both ViewModels call `singleton(UsersCollection)` and get the same instance. When `UsersViewModel` calls `collection.reset()`, `OnDutyViewModel`'s subscription fires automatically.
236
+
237
+ ---
238
+
239
+ ## Test Cleanup
240
+
241
+ Always call `teardownAll()` in `beforeEach` to ensure test isolation:
242
+
243
+ ```typescript
244
+ import { singleton, teardownAll } from 'mvc-kit';
245
+
246
+ beforeEach(() => {
247
+ teardownAll();
248
+ });
249
+ ```
250
+
251
+ Without this, a singleton created in one test leaks into the next — causing shared state, stale subscriptions, and flaky tests.
252
+
253
+ Use `teardown(Class)` when you need to reset a single singleton mid-test without affecting others:
254
+
255
+ ```typescript
256
+ test('re-creation after teardown', () => {
257
+ const svc = singleton(UserService);
258
+ // ...use service...
259
+
260
+ teardown(UserService);
261
+ // UserService is disposed, but UsersCollection is untouched
262
+
263
+ const freshSvc = singleton(UserService);
264
+ // freshSvc is a new instance with a fresh disposeSignal
265
+ });
266
+ ```
267
+
268
+ ---
269
+
270
+ ## Best Practices
271
+
272
+ **Use property initializers, not `onInit`.** Resolve singletons as class fields so they're available immediately — don't wait for `onInit()`:
273
+
274
+ ```typescript
275
+ // Good: available as soon as the ViewModel is constructed
276
+ private service = singleton(LocationService);
277
+
278
+ // Unnecessary: delays resolution to init time
279
+ protected onInit() {
280
+ this.service = singleton(LocationService);
281
+ }
282
+ ```
283
+
284
+ **Don't pass singleton instances as constructor arguments.** Let each class resolve its own dependencies via `singleton()`. This keeps dependencies explicit and avoids manual wiring:
285
+
286
+ ```typescript
287
+ // Bad: manual wiring
288
+ const svc = singleton(UserService);
289
+ const vm = new UsersViewModel(svc, { items: [] });
290
+
291
+ // Good: ViewModel resolves its own dependencies
292
+ const vm = new UsersViewModel({ items: [] });
293
+ // internally: private service = singleton(UserService);
294
+ ```
295
+
296
+ **Always `teardownAll()` in tests.** A leaked singleton is the most common cause of flaky tests in mvc-kit applications.
297
+
298
+ **Define `static DEFAULT_STATE` on singleton ViewModels.** When a ViewModel is used as a singleton, declare the initial state once on the class instead of repeating it at every `singleton()` and `useSingleton()` call site:
299
+
300
+ ```typescript
301
+ class AuthViewModel extends ViewModel<AuthState> {
302
+ static DEFAULT_STATE: AuthState = { user: null, isAuthenticated: false };
303
+ }
304
+
305
+ // Consumers call without args:
306
+ private auth = singleton(AuthViewModel);
307
+ const [state, vm] = useSingleton(AuthViewModel);
308
+ ```
309
+
310
+ **Don't store derived or temporary objects as singletons.** Singletons are for long-lived shared infrastructure. If an object is scoped to a component or workflow, use `useLocal` or direct construction instead.
@@ -0,0 +1,204 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { ViewModel } from './ViewModel';
3
+ import { singleton, hasSingleton, teardown, teardownAll } from './singleton';
4
+
5
+ interface CountState {
6
+ count: number;
7
+ }
8
+
9
+ class CounterViewModel extends ViewModel<CountState> {
10
+ increment() {
11
+ this.set({ count: this.state.count + 1 });
12
+ }
13
+ }
14
+
15
+ class AnotherViewModel extends ViewModel<CountState> {
16
+ decrement() {
17
+ this.set({ count: this.state.count - 1 });
18
+ }
19
+ }
20
+
21
+ describe('singleton', () => {
22
+ beforeEach(() => {
23
+ teardownAll();
24
+ });
25
+
26
+ describe('singleton()', () => {
27
+ it('creates instance on first call', () => {
28
+ const vm = singleton(CounterViewModel, { count: 0 });
29
+ expect(vm).toBeInstanceOf(CounterViewModel);
30
+ expect(vm.state.count).toBe(0);
31
+ });
32
+
33
+ it('returns same instance on subsequent calls', () => {
34
+ const vm1 = singleton(CounterViewModel, { count: 0 });
35
+ vm1.increment();
36
+
37
+ const vm2 = singleton(CounterViewModel, { count: 100 }); // Args ignored
38
+ expect(vm2).toBe(vm1);
39
+ expect(vm2.state.count).toBe(1); // Keeps state from first instance
40
+ });
41
+
42
+ it('creates new instance if previous disposed', () => {
43
+ const vm1 = singleton(CounterViewModel, { count: 0 });
44
+ vm1.increment();
45
+ vm1.dispose();
46
+
47
+ const vm2 = singleton(CounterViewModel, { count: 0 });
48
+ expect(vm2).not.toBe(vm1);
49
+ expect(vm2.state.count).toBe(0);
50
+ expect(vm2.disposed).toBe(false);
51
+ });
52
+
53
+ it('maintains separate singletons per class', () => {
54
+ const counter = singleton(CounterViewModel, { count: 10 });
55
+ const another = singleton(AnotherViewModel, { count: 20 });
56
+
57
+ expect(counter).not.toBe(another);
58
+ expect(counter.state.count).toBe(10);
59
+ expect(another.state.count).toBe(20);
60
+ });
61
+ });
62
+
63
+ describe('hasSingleton()', () => {
64
+ it('returns false when no singleton exists', () => {
65
+ expect(hasSingleton(CounterViewModel)).toBe(false);
66
+ });
67
+
68
+ it('returns true when singleton exists', () => {
69
+ singleton(CounterViewModel, { count: 0 });
70
+ expect(hasSingleton(CounterViewModel)).toBe(true);
71
+ });
72
+
73
+ it('returns false after singleton is disposed', () => {
74
+ const vm = singleton(CounterViewModel, { count: 0 });
75
+ expect(hasSingleton(CounterViewModel)).toBe(true);
76
+ vm.dispose();
77
+ expect(hasSingleton(CounterViewModel)).toBe(false);
78
+ });
79
+
80
+ it('returns false after teardown', () => {
81
+ singleton(CounterViewModel, { count: 0 });
82
+ expect(hasSingleton(CounterViewModel)).toBe(true);
83
+ teardown(CounterViewModel);
84
+ expect(hasSingleton(CounterViewModel)).toBe(false);
85
+ });
86
+ });
87
+
88
+ describe('teardown()', () => {
89
+ it('disposes and removes from registry', () => {
90
+ const vm1 = singleton(CounterViewModel, { count: 0 });
91
+ vm1.increment();
92
+
93
+ teardown(CounterViewModel);
94
+
95
+ expect(vm1.disposed).toBe(true);
96
+
97
+ const vm2 = singleton(CounterViewModel, { count: 0 });
98
+ expect(vm2).not.toBe(vm1);
99
+ });
100
+
101
+ it('handles teardown of non-existent singleton', () => {
102
+ // Should not throw
103
+ expect(() => teardown(CounterViewModel)).not.toThrow();
104
+ });
105
+ });
106
+
107
+ describe('teardownAll()', () => {
108
+ it('disposes all singletons', () => {
109
+ const counter = singleton(CounterViewModel, { count: 0 });
110
+ const another = singleton(AnotherViewModel, { count: 0 });
111
+
112
+ teardownAll();
113
+
114
+ expect(counter.disposed).toBe(true);
115
+ expect(another.disposed).toBe(true);
116
+ });
117
+
118
+ it('clears registry allowing new instances', () => {
119
+ const vm1 = singleton(CounterViewModel, { count: 5 });
120
+ teardownAll();
121
+
122
+ const vm2 = singleton(CounterViewModel, { count: 10 });
123
+ expect(vm2).not.toBe(vm1);
124
+ expect(vm2.state.count).toBe(10);
125
+ });
126
+
127
+ it('handles empty registry', () => {
128
+ expect(() => teardownAll()).not.toThrow();
129
+ });
130
+ });
131
+
132
+ describe('DEFAULT_STATE', () => {
133
+ class DefaultCounterVM extends ViewModel<CountState> {
134
+ static DEFAULT_STATE: CountState = { count: 42 };
135
+
136
+ increment() {
137
+ this.set({ count: this.state.count + 1 });
138
+ }
139
+ }
140
+
141
+ it('uses DEFAULT_STATE when no args passed', () => {
142
+ const vm = singleton(DefaultCounterVM);
143
+ expect(vm).toBeInstanceOf(DefaultCounterVM);
144
+ expect(vm.state.count).toBe(42);
145
+ });
146
+
147
+ it('returns cached instance on subsequent no-arg calls', () => {
148
+ const vm1 = singleton(DefaultCounterVM);
149
+ vm1.increment();
150
+
151
+ const vm2 = singleton(DefaultCounterVM);
152
+ expect(vm2).toBe(vm1);
153
+ expect(vm2.state.count).toBe(43);
154
+ });
155
+
156
+ it('explicit args override DEFAULT_STATE on first creation', () => {
157
+ const vm = singleton(DefaultCounterVM, { count: 99 });
158
+ expect(vm.state.count).toBe(99);
159
+ });
160
+
161
+ it('re-creates with DEFAULT_STATE after teardown', () => {
162
+ const vm1 = singleton(DefaultCounterVM);
163
+ vm1.increment();
164
+ teardown(DefaultCounterVM);
165
+
166
+ const vm2 = singleton(DefaultCounterVM);
167
+ expect(vm2).not.toBe(vm1);
168
+ expect(vm2.state.count).toBe(42);
169
+ });
170
+
171
+ it('re-creates with DEFAULT_STATE after dispose', () => {
172
+ const vm1 = singleton(DefaultCounterVM);
173
+ vm1.increment();
174
+ vm1.dispose();
175
+
176
+ const vm2 = singleton(DefaultCounterVM);
177
+ expect(vm2).not.toBe(vm1);
178
+ expect(vm2.state.count).toBe(42);
179
+ });
180
+
181
+ it('DEFAULT_STATE object is not mutated by instance operations', () => {
182
+ const original = { ...DefaultCounterVM.DEFAULT_STATE };
183
+ const vm = singleton(DefaultCounterVM);
184
+ vm.increment();
185
+ vm.increment();
186
+
187
+ expect(DefaultCounterVM.DEFAULT_STATE).toEqual(original);
188
+ });
189
+ });
190
+
191
+ describe('signal lifecycle', () => {
192
+ it('re-created singleton after teardown has fresh un-aborted signal', () => {
193
+ const vm1 = singleton(CounterViewModel, { count: 0 });
194
+ const signal1 = vm1.disposeSignal;
195
+ teardown(CounterViewModel);
196
+ expect(signal1.aborted).toBe(true);
197
+
198
+ const vm2 = singleton(CounterViewModel, { count: 0 });
199
+ expect(vm2).not.toBe(vm1);
200
+ expect(vm2.disposeSignal.aborted).toBe(false);
201
+ expect(vm2.disposeSignal).not.toBe(signal1);
202
+ });
203
+ });
204
+ });