mvc-kit 2.12.0 → 2.12.2
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/agent-config/bin/postinstall.mjs +5 -3
- package/agent-config/bin/setup.mjs +3 -4
- package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
- package/agent-config/lib/install-claude.mjs +19 -33
- package/dist/Model.cjs +9 -1
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.d.ts +1 -1
- package/dist/Model.d.ts.map +1 -1
- package/dist/Model.js +9 -1
- package/dist/Model.js.map +1 -1
- package/dist/ViewModel.cjs +9 -1
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.d.ts +1 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/ViewModel.js +9 -1
- package/dist/ViewModel.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +3 -0
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +3 -0
- package/dist/mvc-kit.js.map +1 -1
- package/dist/produceDraft.cjs +105 -0
- package/dist/produceDraft.cjs.map +1 -0
- package/dist/produceDraft.d.ts +19 -0
- package/dist/produceDraft.d.ts.map +1 -0
- package/dist/produceDraft.js +105 -0
- package/dist/produceDraft.js.map +1 -0
- package/package.json +4 -2
- package/src/Channel.md +408 -0
- package/src/Channel.test.ts +957 -0
- package/src/Channel.ts +429 -0
- package/src/Collection.md +533 -0
- package/src/Collection.test.ts +1559 -0
- package/src/Collection.ts +653 -0
- package/src/Controller.md +306 -0
- package/src/Controller.test.ts +380 -0
- package/src/Controller.ts +90 -0
- package/src/EventBus.md +308 -0
- package/src/EventBus.test.ts +295 -0
- package/src/EventBus.ts +110 -0
- package/src/Feed.md +218 -0
- package/src/Feed.test.ts +442 -0
- package/src/Feed.ts +101 -0
- package/src/Model.md +524 -0
- package/src/Model.test.ts +642 -0
- package/src/Model.ts +260 -0
- package/src/Pagination.md +168 -0
- package/src/Pagination.test.ts +244 -0
- package/src/Pagination.ts +92 -0
- package/src/Pending.md +380 -0
- package/src/Pending.test.ts +1719 -0
- package/src/Pending.ts +390 -0
- package/src/PersistentCollection.md +183 -0
- package/src/PersistentCollection.test.ts +649 -0
- package/src/PersistentCollection.ts +375 -0
- package/src/Resource.ViewModel.test.ts +503 -0
- package/src/Resource.md +239 -0
- package/src/Resource.test.ts +786 -0
- package/src/Resource.ts +231 -0
- package/src/Selection.md +155 -0
- package/src/Selection.test.ts +326 -0
- package/src/Selection.ts +117 -0
- package/src/Service.md +440 -0
- package/src/Service.test.ts +241 -0
- package/src/Service.ts +72 -0
- package/src/Sorting.md +170 -0
- package/src/Sorting.test.ts +334 -0
- package/src/Sorting.ts +135 -0
- package/src/Trackable.md +166 -0
- package/src/Trackable.test.ts +236 -0
- package/src/Trackable.ts +129 -0
- package/src/ViewModel.async.test.ts +813 -0
- package/src/ViewModel.derived.test.ts +1583 -0
- package/src/ViewModel.md +1111 -0
- package/src/ViewModel.test.ts +1236 -0
- package/src/ViewModel.ts +800 -0
- package/src/bindPublicMethods.test.ts +126 -0
- package/src/bindPublicMethods.ts +48 -0
- package/src/env.d.ts +5 -0
- package/src/errors.test.ts +155 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +49 -0
- package/src/produceDraft.md +90 -0
- package/src/produceDraft.test.ts +394 -0
- package/src/produceDraft.ts +168 -0
- package/src/react/components/CardList.md +97 -0
- package/src/react/components/CardList.test.tsx +142 -0
- package/src/react/components/CardList.tsx +68 -0
- package/src/react/components/DataTable.md +179 -0
- package/src/react/components/DataTable.test.tsx +599 -0
- package/src/react/components/DataTable.tsx +267 -0
- package/src/react/components/InfiniteScroll.md +116 -0
- package/src/react/components/InfiniteScroll.test.tsx +218 -0
- package/src/react/components/InfiniteScroll.tsx +70 -0
- package/src/react/components/types.ts +90 -0
- package/src/react/derived.test.tsx +261 -0
- package/src/react/guards.ts +24 -0
- package/src/react/index.ts +40 -0
- package/src/react/provider.test.tsx +143 -0
- package/src/react/provider.tsx +55 -0
- package/src/react/strict-mode.test.tsx +266 -0
- package/src/react/types.ts +25 -0
- package/src/react/use-event-bus.md +214 -0
- package/src/react/use-event-bus.test.tsx +168 -0
- package/src/react/use-event-bus.ts +40 -0
- package/src/react/use-instance.md +204 -0
- package/src/react/use-instance.test.tsx +350 -0
- package/src/react/use-instance.ts +60 -0
- package/src/react/use-local.md +457 -0
- package/src/react/use-local.rapid-remount.test.tsx +503 -0
- package/src/react/use-local.test.tsx +692 -0
- package/src/react/use-local.ts +165 -0
- package/src/react/use-model.md +364 -0
- package/src/react/use-model.test.tsx +394 -0
- package/src/react/use-model.ts +161 -0
- package/src/react/use-singleton.md +415 -0
- package/src/react/use-singleton.test.tsx +296 -0
- package/src/react/use-singleton.ts +69 -0
- package/src/react/use-subscribe-only.ts +39 -0
- package/src/react/use-teardown.md +169 -0
- package/src/react/use-teardown.test.tsx +86 -0
- package/src/react/use-teardown.ts +27 -0
- package/src/react-native/NativeCollection.test.ts +250 -0
- package/src/react-native/NativeCollection.ts +138 -0
- package/src/react-native/index.ts +1 -0
- package/src/singleton.md +310 -0
- package/src/singleton.test.ts +204 -0
- package/src/singleton.ts +70 -0
- package/src/types.ts +70 -0
- package/src/walkPrototypeChain.ts +22 -0
- package/src/web/IndexedDBCollection.test.ts +235 -0
- package/src/web/IndexedDBCollection.ts +66 -0
- package/src/web/WebStorageCollection.test.ts +214 -0
- package/src/web/WebStorageCollection.ts +116 -0
- package/src/web/idb.ts +184 -0
- package/src/web/index.ts +2 -0
- 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';
|
package/src/singleton.md
ADDED
|
@@ -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
|
+
});
|