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
package/src/ViewModel.md
ADDED
|
@@ -0,0 +1,1111 @@
|
|
|
1
|
+
# ViewModel
|
|
2
|
+
|
|
3
|
+
The core building block for UI state management. A ViewModel holds state, exposes computed getters, tracks async operations, emits typed events, and provides a complete reactive interface for a single component.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Role
|
|
8
|
+
|
|
9
|
+
The ViewModel is the complete interface between a component and everything else. It encapsulates services, collections, subscriptions, async orchestration, and derived computations. The component never imports infrastructure — it reads state, reads getters, reads async status, and calls methods.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
Component → ViewModel → Service / Collection / EventBus / Channel
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
One ViewModel per connected component. If a component needs two ViewModels, split it into two components.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Generic Parameters
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
class MyViewModel extends ViewModel<S, E = {}>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
| Parameter | Purpose |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `S extends object` | The state shape. Must be a plain object. |
|
|
28
|
+
| `E extends Record<string, any>` | Optional typed event map for imperative events. Defaults to `{}` (no events). |
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Creating a ViewModel
|
|
33
|
+
|
|
34
|
+
### Subclass and Define State
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
interface State {
|
|
38
|
+
items: Item[];
|
|
39
|
+
search: string;
|
|
40
|
+
typeFilter: 'all' | 'office' | 'warehouse';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class LocationsViewModel extends ViewModel<State> {
|
|
44
|
+
// ...
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Construction
|
|
49
|
+
|
|
50
|
+
The constructor accepts the initial state object. State is frozen on construction.
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
const vm = new LocationsViewModel({
|
|
54
|
+
items: [],
|
|
55
|
+
search: '',
|
|
56
|
+
typeFilter: 'all',
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
State is always immutable — `Object.freeze` is applied on every state transition.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Properties
|
|
65
|
+
|
|
66
|
+
| Property | Type | Description |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `state` | `S` | Current frozen state object |
|
|
69
|
+
| `disposed` | `boolean` | Whether `dispose()` has been called |
|
|
70
|
+
| `initialized` | `boolean` | Whether `init()` has been called |
|
|
71
|
+
| `disposeSignal` | `AbortSignal` | Lazily created; aborted on `dispose()` |
|
|
72
|
+
| `events` | `EventBus<E>` | Lazily created typed event bus |
|
|
73
|
+
| `async` | `AsyncMap<this>` | Proxy returning `TaskState` per async method |
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## State Management
|
|
78
|
+
|
|
79
|
+
### set(partial) — protected
|
|
80
|
+
|
|
81
|
+
Merges partial state into current state. Produces a new frozen state object and notifies all subscribers (React re-renders).
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
// Partial object
|
|
85
|
+
this.set({ search: 'alice' });
|
|
86
|
+
|
|
87
|
+
// Updater function — return a partial
|
|
88
|
+
this.set(prev => ({ count: prev.count + 1 }));
|
|
89
|
+
|
|
90
|
+
// Draft mode — mutate the draft, return nothing
|
|
91
|
+
this.set(draft => { draft.count = draft.count + 1 });
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
When using the function form, the state is wrapped in a copy-on-write draft proxy (see [`produceDraft`](./produceDraft.md)). If the function returns an object, that object is used as the partial (existing updater behavior). If the function returns `void`, the draft mutations are collected and applied. Both styles can be used interchangeably — pick whichever reads better for the update at hand.
|
|
95
|
+
|
|
96
|
+
Draft mode is especially useful for nested state updates:
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
this.set(draft => { draft.filters.status = 'active' });
|
|
100
|
+
// vs.
|
|
101
|
+
this.set(prev => ({ filters: { ...prev.filters, status: 'active' } }));
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Structural sharing is preserved — unchanged subtrees keep their original references. Arrays must be replaced via assignment (in-place `push`/`splice` is not detected).
|
|
105
|
+
|
|
106
|
+
**Shallow equality check:** If no values actually changed by reference, `set()` is a no-op — no new state object, no notification, no re-render.
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
// Given state { count: 0, name: 'test' }
|
|
110
|
+
this.set({ count: 0 }); // no-op — value unchanged
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**After dispose:** `set()` is a silent no-op. This is intentional — in-flight async callbacks that resolve after dispose can safely call `set()` without crashing.
|
|
114
|
+
|
|
115
|
+
### State is Always Frozen
|
|
116
|
+
|
|
117
|
+
Every state object is `Object.freeze`'d. Direct mutation throws:
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
vm.state.search = 'test'; // TypeError: Cannot assign to read only property
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Lifecycle
|
|
126
|
+
|
|
127
|
+
### init()
|
|
128
|
+
|
|
129
|
+
Initializes the ViewModel. Sets up auto-memoized getters, subscribable auto-tracking, method wrapping for async tracking, and calls `onInit()`. Idempotent — calling it multiple times has no effect. No-op if already disposed.
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
vm.init();
|
|
133
|
+
vm.init(); // no-op
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Supports async initialization:
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
class MyVM extends ViewModel<State> {
|
|
140
|
+
protected async onInit() {
|
|
141
|
+
const data = await this.service.getAll(this.disposeSignal);
|
|
142
|
+
this.collection.reset(data);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await vm.init();
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
When using `useLocal`, `init()` is called automatically after mount — you never call it manually in components.
|
|
150
|
+
|
|
151
|
+
### Init Sequence
|
|
152
|
+
|
|
153
|
+
When `init()` is called, the following happens in order:
|
|
154
|
+
|
|
155
|
+
1. `_trackSubscribables()` — detects subscribable members and wires up auto-tracking
|
|
156
|
+
2. `_installStateProxy()` — installs context-sensitive state getter for dependency tracking
|
|
157
|
+
3. `_processMembers()` — memoizes getters and wraps methods for async tracking (delegates to shared `wrapAsyncMethods`)
|
|
158
|
+
4. `onInit()` — user-defined initialization hook
|
|
159
|
+
|
|
160
|
+
### dispose()
|
|
161
|
+
|
|
162
|
+
Tears down the ViewModel:
|
|
163
|
+
|
|
164
|
+
1. Sets `disposed` to `true`
|
|
165
|
+
2. Tears down all subscribable and `subscribeTo` subscriptions
|
|
166
|
+
3. Aborts `disposeSignal`
|
|
167
|
+
4. Runs all `addCleanup()` callbacks (including async tracking cleanup)
|
|
168
|
+
5. Disposes the event bus (if created)
|
|
169
|
+
6. Calls `onDispose()`
|
|
170
|
+
7. Clears all state listeners
|
|
171
|
+
|
|
172
|
+
Idempotent — safe to call multiple times. `onDispose()` only runs once.
|
|
173
|
+
|
|
174
|
+
### reset(newState?)
|
|
175
|
+
|
|
176
|
+
Tears down the current lifecycle and re-initializes without unmounting the component. Accepts an optional new state; defaults to the original state passed to the constructor.
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
// Reset to initial state
|
|
180
|
+
vm.reset();
|
|
181
|
+
|
|
182
|
+
// Reset with new state
|
|
183
|
+
vm.reset({ userId: newId, data: null });
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Reset performs the following in order:
|
|
187
|
+
|
|
188
|
+
1. Aborts the current `disposeSignal` (nulls the AbortController — a fresh one is lazily created on next access)
|
|
189
|
+
2. Tears down all subscriptions (subscribable + `subscribeTo`)
|
|
190
|
+
3. Resets state to `newState` or `initialState`
|
|
191
|
+
4. Clears all async tracking (states + snapshots), notifies async listeners
|
|
192
|
+
5. Re-runs `_trackSubscribables()` (fresh subscriptions to subscribable members)
|
|
193
|
+
6. Notifies state listeners (React re-renders with new state)
|
|
194
|
+
7. Re-runs `onInit()`
|
|
195
|
+
|
|
196
|
+
**After dispose:** `reset()` is a no-op.
|
|
197
|
+
|
|
198
|
+
**Key detail:** The old `disposeSignal` is aborted, so any in-flight fetch using it will throw `AbortError` and be swallowed. The new `disposeSignal` (accessed after reset) is a fresh, unaborted signal.
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
const oldSignal = vm.disposeSignal;
|
|
202
|
+
vm.reset();
|
|
203
|
+
oldSignal.aborted; // true
|
|
204
|
+
vm.disposeSignal.aborted; // false (new signal)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## Lifecycle Hooks
|
|
210
|
+
|
|
211
|
+
### onInit?(): void | Promise<void> — protected
|
|
212
|
+
|
|
213
|
+
Called during `init()` after all internal setup is complete. This is where you kick off initial loads and set up imperative subscriptions.
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
protected onInit() {
|
|
217
|
+
// Smart init: skip fetch if another ViewModel already loaded the data
|
|
218
|
+
if (this.collection.length === 0) this.load();
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Collection data is typically accessed via getters that read from collection members directly — auto-tracking handles reactivity without needing `subscribeTo()` + `set()`. See `BEST_PRACTICES.md` for the full pattern.
|
|
223
|
+
|
|
224
|
+
Can be async — `init()` returns the promise so callers can `await` it.
|
|
225
|
+
|
|
226
|
+
### onSet?(prev, next): void — protected
|
|
227
|
+
|
|
228
|
+
Called after every successful `set()` with the previous and next state. Useful for side-effect logic that reacts to specific state transitions.
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
protected onSet(prev: Readonly<State>, next: Readonly<State>) {
|
|
232
|
+
if (prev.search !== next.search) {
|
|
233
|
+
this.analytics.track('search', { query: next.search });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Not called when `set()` is a no-op (values unchanged).
|
|
239
|
+
|
|
240
|
+
### onDispose?(): void — protected
|
|
241
|
+
|
|
242
|
+
Called once during `dispose()`, after the signal is aborted, cleanups have fired, and the event bus is disposed.
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
protected onDispose() {
|
|
246
|
+
this.model?.dispose();
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## Computed Getters (Auto-Memoized)
|
|
253
|
+
|
|
254
|
+
TypeScript `get` accessors compute derived values from state and subscribable members. After `init()`, all subclass getters are automatically wrapped with a memoization layer that tracks dependencies and caches results.
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
get filtered(): Item[] {
|
|
258
|
+
const { items, search, statusFilter } = this.state;
|
|
259
|
+
let result = items;
|
|
260
|
+
if (search) {
|
|
261
|
+
const q = search.toLowerCase();
|
|
262
|
+
result = result.filter(i => i.name.toLowerCase().includes(q));
|
|
263
|
+
}
|
|
264
|
+
if (statusFilter !== 'all') {
|
|
265
|
+
result = result.filter(i => i.status === statusFilter);
|
|
266
|
+
}
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
get total(): number {
|
|
271
|
+
return this.state.items.length;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
get hasResults(): boolean {
|
|
275
|
+
return this.filtered.length > 0;
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Dependency Tracking
|
|
280
|
+
|
|
281
|
+
When a memoized getter executes, a proxy records which `this.state` properties are accessed. These become the getter's dependencies. The getter only recomputes when one of those specific properties changes — not on any state change.
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
// This getter depends on state.search and state.statusFilter
|
|
285
|
+
get filtered() {
|
|
286
|
+
const { search, statusFilter } = this.state;
|
|
287
|
+
// ...
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Changing state.loading does NOT invalidate `filtered`
|
|
291
|
+
this.set({ loading: true }); // filtered returns cached value
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Subscribable Dependencies
|
|
295
|
+
|
|
296
|
+
Getters can also depend on subscribable members (Collections, Channels, other ViewModels). The memoization system auto-detects subscribable instance properties and tracks their revisions.
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
class MyVM extends ViewModel<State> {
|
|
300
|
+
collection = new UsersCollection();
|
|
301
|
+
|
|
302
|
+
get filtered(): User[] {
|
|
303
|
+
return this.collection.items.filter(u => u.name.includes(this.state.search));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
When `collection` notifies (e.g., after `reset()`), the getter's cache invalidates and it recomputes on next access.
|
|
309
|
+
|
|
310
|
+
### Three-Tier Cache
|
|
311
|
+
|
|
312
|
+
The memoization system uses a three-tier validation strategy:
|
|
313
|
+
|
|
314
|
+
| Tier | Check | Cost | When |
|
|
315
|
+
|---|---|---|---|
|
|
316
|
+
| **Tier 1** | Global revision unchanged | 1 integer compare | No `set()` or subscribable notification since last access |
|
|
317
|
+
| **Tier 2** | Revision changed but this getter's deps didn't | Check each dep by reference | Unrelated state or subscribable changed |
|
|
318
|
+
| **Tier 3** | At least one dep changed | Full recompute with tracking | Actual dependency changed |
|
|
319
|
+
|
|
320
|
+
This means:
|
|
321
|
+
- Accessing the same getter multiple times in one render is effectively free (Tier 1).
|
|
322
|
+
- Changing unrelated state properties doesn't recompute the getter (Tier 2).
|
|
323
|
+
- Only actual dependency changes trigger recomputation (Tier 3).
|
|
324
|
+
|
|
325
|
+
### Conditional Dependencies
|
|
326
|
+
|
|
327
|
+
Dependencies are re-tracked on every recomputation. If a getter has conditional branches, its dependencies update based on which branch runs:
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
get display(): string {
|
|
331
|
+
if (this.state.mode === 'compact') return this.state.title;
|
|
332
|
+
return `${this.state.title} — ${this.state.subtitle}`;
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
In `compact` mode, changing `subtitle` does NOT invalidate the getter (it wasn't accessed). After switching to `full` mode, `subtitle` becomes a dependency and changes will trigger recomputation.
|
|
337
|
+
|
|
338
|
+
### Getter Composition
|
|
339
|
+
|
|
340
|
+
Getters can call other getters. Dependency tracking bubbles up correctly:
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
get filtered(): Item[] { /* depends on state.search, state.statusFilter, collection */ }
|
|
344
|
+
get hasResults(): boolean { return this.filtered.length > 0; } // inherits filtered's deps
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
When `state.search` changes, both `filtered` and `hasResults` recompute.
|
|
348
|
+
|
|
349
|
+
### Getter Inheritance
|
|
350
|
+
|
|
351
|
+
Getters from parent classes are memoized. If a subclass overrides a parent getter, only the most-derived version is wrapped.
|
|
352
|
+
|
|
353
|
+
```typescript
|
|
354
|
+
class BaseVM extends ViewModel<State> {
|
|
355
|
+
get isEmpty(): boolean { return this.state.items.length === 0; }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
class DerivedVM extends BaseVM {
|
|
359
|
+
get filtered(): Item[] { /* ... */ }
|
|
360
|
+
}
|
|
361
|
+
// Both isEmpty and filtered are memoized
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Error Handling in Getters
|
|
365
|
+
|
|
366
|
+
If a getter throws, the error propagates and the cache is NOT populated. The next access will recompute (and throw again if the underlying condition hasn't changed).
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
get data(): Item {
|
|
370
|
+
if (!this.state.item) throw new Error('No item loaded');
|
|
371
|
+
return this.state.item;
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### After Dispose
|
|
376
|
+
|
|
377
|
+
Getter access after dispose returns the last cached value without re-executing the getter body. This prevents errors from stale data access during cleanup.
|
|
378
|
+
|
|
379
|
+
### Rules
|
|
380
|
+
|
|
381
|
+
- **Never call `set()` inside a getter.** It creates an infinite loop. Dev mode detects this and logs an error (the `set()` call is blocked).
|
|
382
|
+
- **Getters must be pure.** They read state and return a value. No side effects.
|
|
383
|
+
- **Before `init()`**, getters work as plain (unmemoized) accessors.
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## Subscribable Auto-Detection
|
|
388
|
+
|
|
389
|
+
After `init()`, the ViewModel scans all own instance properties. Any property that has a `subscribe` method (duck-typed) is treated as a subscribable member and auto-tracked.
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
class MyVM extends ViewModel<State> {
|
|
393
|
+
collection = singleton(UsersCollection); // auto-tracked ✓
|
|
394
|
+
channel = singleton(ChatChannel); // auto-tracked ✓
|
|
395
|
+
plainObject = { value: 42 }; // ignored (no subscribe method)
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
When a subscribable member notifies:
|
|
400
|
+
|
|
401
|
+
1. The ViewModel's internal revision counter increments
|
|
402
|
+
2. Memoized getter caches that depend on that member invalidate
|
|
403
|
+
3. A new state reference is forced (even though values are unchanged) so React's `useSyncExternalStore` detects the change
|
|
404
|
+
4. State listeners fire, triggering a re-render
|
|
405
|
+
|
|
406
|
+
If the member also has `subscribeAsync` (ViewModels and Resources), async state changes (loading/error transitions) also trigger getter invalidation. This means a parent ViewModel can have a getter like `get isChildLoading() { return this.childVM.async.loadData.loading; }` and it will update automatically.
|
|
407
|
+
|
|
408
|
+
**EventBus does NOT have `subscribe`** — it won't be auto-tracked (by design). Use `subscribeTo` for explicit subscriptions.
|
|
409
|
+
|
|
410
|
+
### Multiple Subscribable Members
|
|
411
|
+
|
|
412
|
+
Each subscribable member is tracked independently. A getter that reads `collection1` but not `collection2` won't recompute when `collection2` changes.
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
## Async Tracking
|
|
417
|
+
|
|
418
|
+
After `init()`, all subclass methods are automatically wrapped. When a method returns a Promise, the framework tracks its loading and error state.
|
|
419
|
+
|
|
420
|
+
### How It Works
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
async load() {
|
|
424
|
+
const data = await this.service.getAll(this.disposeSignal);
|
|
425
|
+
this.collection.reset(data);
|
|
426
|
+
}
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
When `load()` is called:
|
|
430
|
+
|
|
431
|
+
1. `vm.async.load` becomes `{ loading: true, error: null, errorCode: null }`
|
|
432
|
+
2. Async listeners are notified (React re-renders)
|
|
433
|
+
3. On resolve: `vm.async.load` becomes `{ loading: false, error: null, errorCode: null }`
|
|
434
|
+
4. On reject: error is classified via `classifyError()` — `vm.async.load.error` gets the message, `errorCode` gets a discriminant code
|
|
435
|
+
5. On `AbortError`: silently swallowed — not captured as an error, not re-thrown
|
|
436
|
+
|
|
437
|
+
### TaskState
|
|
438
|
+
|
|
439
|
+
Each tracked async method has a frozen `TaskState` snapshot:
|
|
440
|
+
|
|
441
|
+
```typescript
|
|
442
|
+
interface TaskState {
|
|
443
|
+
readonly loading: boolean;
|
|
444
|
+
readonly error: string | null;
|
|
445
|
+
readonly errorCode: AppError['code'] | null;
|
|
446
|
+
// errorCode: 'unauthorized' | 'forbidden' | 'not_found' | 'validation' |
|
|
447
|
+
// 'rate_limited' | 'server_error' | 'network' | 'timeout' |
|
|
448
|
+
// 'abort' | 'unknown' | null
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
Default (before the method is ever called): `{ loading: false, error: null, errorCode: null }`.
|
|
453
|
+
|
|
454
|
+
### Reading Async State
|
|
455
|
+
|
|
456
|
+
```typescript
|
|
457
|
+
const { loading, error, errorCode } = vm.async.load;
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
The `async` property is a Proxy. Accessing any key returns the `TaskState` for that method, or the default if the method hasn't been called.
|
|
461
|
+
|
|
462
|
+
### Snapshot Reference Stability
|
|
463
|
+
|
|
464
|
+
Each `TaskState` is a frozen object. The reference changes on every status update (loading start, loading end, error). This enables React's `useSyncExternalStore` to detect changes via reference comparison.
|
|
465
|
+
|
|
466
|
+
### Concurrent Calls
|
|
467
|
+
|
|
468
|
+
Loading state uses a counter. If the same method is called multiple times concurrently, `loading` stays `true` until ALL calls resolve:
|
|
469
|
+
|
|
470
|
+
```typescript
|
|
471
|
+
const p1 = vm.load(); // loading: true (count: 1)
|
|
472
|
+
const p2 = vm.load(); // loading: true (count: 2)
|
|
473
|
+
await p1; // loading: true (count: 1)
|
|
474
|
+
await p2; // loading: false (count: 0)
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
### Error Clearing
|
|
478
|
+
|
|
479
|
+
When a method is retried after failure, `error` and `errorCode` are cleared at the start of the new call:
|
|
480
|
+
|
|
481
|
+
```typescript
|
|
482
|
+
await vm.load().catch(() => {});
|
|
483
|
+
vm.async.load.error; // 'something went wrong'
|
|
484
|
+
|
|
485
|
+
vm.load(); // immediately: { loading: true, error: null, errorCode: null }
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
### Return Value Preservation
|
|
489
|
+
|
|
490
|
+
Wrapped methods preserve their return values:
|
|
491
|
+
|
|
492
|
+
```typescript
|
|
493
|
+
async fetchData(): Promise<string> {
|
|
494
|
+
return 'data';
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const result = await vm.fetchData(); // 'data'
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
Errors are re-thrown (except AbortError) so standard Promise rejection behavior is preserved:
|
|
501
|
+
|
|
502
|
+
```typescript
|
|
503
|
+
await expect(vm.failingMethod()).rejects.toThrow('something went wrong');
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
### AbortError Handling
|
|
507
|
+
|
|
508
|
+
AbortErrors are special-cased **at the method wrapper level**:
|
|
509
|
+
- **Not captured** as an error in `TaskState` (error stays `null`)
|
|
510
|
+
- **Not re-thrown** by the wrapper (the outer promise resolves to `undefined`)
|
|
511
|
+
- **Loading counter decremented** normally
|
|
512
|
+
|
|
513
|
+
This means navigating away from a component (which aborts in-flight fetches via `disposeSignal`) produces no error state and no unhandled rejections.
|
|
514
|
+
|
|
515
|
+
**For methods without try/catch**, this is all you need — write the happy path and AbortErrors are fully automatic.
|
|
516
|
+
|
|
517
|
+
**For methods with explicit try/catch**, your catch block **does** receive AbortErrors. The wrapper intercepts at the outer promise level (after your method's promise settles), not inside your function body. Use `isAbortError()` in the catch block to guard side effects that shouldn't run on abort — particularly rollback functions and other operations on shared state like Collections. `set()` and `emit()` are already no-ops after dispose and don't need guarding.
|
|
518
|
+
|
|
519
|
+
```typescript
|
|
520
|
+
// No try/catch — AbortError is fully automatic:
|
|
521
|
+
async load() {
|
|
522
|
+
const data = await this.service.getAll(this.disposeSignal);
|
|
523
|
+
this.collection.reset(data);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Explicit try/catch with shared-state side effects — guard needed:
|
|
527
|
+
async delete(id: string) {
|
|
528
|
+
const rollback = this.collection.optimistic(() => {
|
|
529
|
+
this.collection.remove(id);
|
|
530
|
+
});
|
|
531
|
+
try {
|
|
532
|
+
await this.service.delete(id, this.disposeSignal);
|
|
533
|
+
} catch (e) {
|
|
534
|
+
if (!isAbortError(e)) rollback(); // don't roll back on abort
|
|
535
|
+
throw e;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
### Sync Method Pruning
|
|
541
|
+
|
|
542
|
+
Methods that return synchronous values on first call are **auto-pruned** from async tracking. The wrapper detects the non-thenable return, removes the method from async maps, and replaces itself with a direct `bind` of the original — zero overhead on subsequent calls.
|
|
543
|
+
|
|
544
|
+
```typescript
|
|
545
|
+
syncMethod(): number {
|
|
546
|
+
return 42;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
vm.syncMethod(); // first call: detected as sync, pruned
|
|
550
|
+
vm.syncMethod(); // subsequent calls: direct bound call, no wrapping overhead
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
### What Gets Wrapped
|
|
554
|
+
|
|
555
|
+
All methods on the subclass prototype chain (up to but not including `ViewModel.prototype`) are wrapped, **except**:
|
|
556
|
+
|
|
557
|
+
- Getters and setters (managed by memoization)
|
|
558
|
+
- `_`-prefixed methods (private convention)
|
|
559
|
+
- Lifecycle hooks: `onInit`, `onSet`, `onDispose`
|
|
560
|
+
- `constructor`
|
|
561
|
+
|
|
562
|
+
### subscribeAsync(listener)
|
|
563
|
+
|
|
564
|
+
Subscribes to async state changes. Returns an unsubscribe function. Used internally by `useInstance` to trigger React re-renders when async status changes.
|
|
565
|
+
|
|
566
|
+
```typescript
|
|
567
|
+
const unsub = vm.subscribeAsync(() => {
|
|
568
|
+
// any async method's TaskState changed
|
|
569
|
+
});
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
Returns a no-op unsubscribe if called after dispose.
|
|
573
|
+
|
|
574
|
+
---
|
|
575
|
+
|
|
576
|
+
## Async Tracking Does NOT Invalidate Getters
|
|
577
|
+
|
|
578
|
+
Async state notifications (`_notifyAsync`) are separate from state notifications. They do NOT bump the revision counter. This means:
|
|
579
|
+
|
|
580
|
+
- Getter caches are NOT invalidated when async status changes
|
|
581
|
+
- Only `set()` calls and subscribable member notifications invalidate getters
|
|
582
|
+
- This prevents unnecessary recomputation of derived state when only loading/error status changes
|
|
583
|
+
|
|
584
|
+
---
|
|
585
|
+
|
|
586
|
+
## Events
|
|
587
|
+
|
|
588
|
+
ViewModels support typed imperative events via the second generic parameter. Events are for one-shot signals that don't belong in state: toast notifications, navigation redirects, scroll-to-error, shake animations.
|
|
589
|
+
|
|
590
|
+
### Defining Events
|
|
591
|
+
|
|
592
|
+
```typescript
|
|
593
|
+
interface Events {
|
|
594
|
+
saved: { id: string };
|
|
595
|
+
deleted: { id: string };
|
|
596
|
+
validationFailed: void;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
class ItemViewModel extends ViewModel<State, Events> {
|
|
600
|
+
async save() {
|
|
601
|
+
const result = await this.service.save(this.state.draft, this.disposeSignal);
|
|
602
|
+
this.emit('saved', { id: result.id });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
### emit(event, payload) — protected
|
|
608
|
+
|
|
609
|
+
Dispatches a typed event. Only the ViewModel can emit — components cannot.
|
|
610
|
+
|
|
611
|
+
```typescript
|
|
612
|
+
this.emit('saved', { id: result.id });
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
**After dispose:** `emit()` is a no-op (after the event bus is disposed). However, cleanup callbacks registered via `addCleanup()` run before the event bus is disposed, so they can still emit:
|
|
616
|
+
|
|
617
|
+
```typescript
|
|
618
|
+
class MyVM extends ViewModel<State, Events> {
|
|
619
|
+
setup() {
|
|
620
|
+
this.addCleanup(() => {
|
|
621
|
+
this.emit('saved', { id: 'final' }); // works — bus not yet disposed
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
### events
|
|
628
|
+
|
|
629
|
+
Lazily-created `EventBus<E>`. Zero cost if the ViewModel never emits. Auto-disposed with the ViewModel.
|
|
630
|
+
|
|
631
|
+
```typescript
|
|
632
|
+
vm.events.on('saved', ({ id }) => {
|
|
633
|
+
toast.success(`Saved ${id}`);
|
|
634
|
+
});
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
### Lazy Allocation
|
|
638
|
+
|
|
639
|
+
If the `events` getter is never accessed and `emit()` is never called, no EventBus is allocated. This keeps non-eventing ViewModels zero-cost.
|
|
640
|
+
|
|
641
|
+
---
|
|
642
|
+
|
|
643
|
+
## Subscriptions
|
|
644
|
+
|
|
645
|
+
### subscribe(listener)
|
|
646
|
+
|
|
647
|
+
Subscribes to state changes. Returns an unsubscribe function. Listeners receive `(nextState, prevState)`.
|
|
648
|
+
|
|
649
|
+
```typescript
|
|
650
|
+
const unsub = vm.subscribe((next, prev) => {
|
|
651
|
+
console.log(`count: ${prev.count} → ${next.count}`);
|
|
652
|
+
});
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
Returns a no-op unsubscribe if called after dispose.
|
|
656
|
+
|
|
657
|
+
Used internally by `useInstance` / `useSyncExternalStore` for React integration.
|
|
658
|
+
|
|
659
|
+
### subscribeTo(source, listener) — protected
|
|
660
|
+
|
|
661
|
+
Subscribes to any `Subscribable` source (Collection, ViewModel, Channel) with automatic cleanup on dispose. Returns an unsubscribe function for manual early cleanup.
|
|
662
|
+
|
|
663
|
+
Use `subscribeTo()` for **imperative reactions** (side effects on change). For deriving values from collections, use getters instead — auto-tracking handles reactivity automatically.
|
|
664
|
+
|
|
665
|
+
```typescript
|
|
666
|
+
protected onInit() {
|
|
667
|
+
// Imperative reaction: play a sound when messages arrive
|
|
668
|
+
this.subscribeTo(this.messagesCollection, () => {
|
|
669
|
+
this.playNotificationSound();
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
### listenTo(source, event, handler) — protected
|
|
675
|
+
|
|
676
|
+
Subscribes to a typed event on a Channel or EventBus with automatic cleanup on dispose and reset. The event counterpart to `subscribeTo`.
|
|
677
|
+
|
|
678
|
+
```typescript
|
|
679
|
+
protected onInit() {
|
|
680
|
+
this.listenTo(this.channel, 'message', (msg) => {
|
|
681
|
+
this.set({ messages: [...this.state.messages, msg] });
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
this.listenTo(this.bus, 'auth:logout', () => {
|
|
685
|
+
this.set({ messages: [] });
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
Returns an unsubscribe function for manual early cleanup. Stored in `_subscriptionCleanups` — torn down on both `dispose()` and `reset()`, then re-established when `onInit()` re-runs.
|
|
691
|
+
|
|
692
|
+
### pipeChannel(channel, type, target) — protected
|
|
693
|
+
|
|
694
|
+
Bridges a Channel event directly into a Collection via `upsert`. Calls `channel.init()` (idempotent) and registers the listener with auto-cleanup on dispose and reset.
|
|
695
|
+
|
|
696
|
+
```typescript
|
|
697
|
+
protected onInit() {
|
|
698
|
+
this.pipeChannel(this.channel, 'data', this.collection);
|
|
699
|
+
|
|
700
|
+
if (this.appState.state.online) {
|
|
701
|
+
this.channel.connect();
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
Equivalent to:
|
|
707
|
+
|
|
708
|
+
```typescript
|
|
709
|
+
this.channel.init();
|
|
710
|
+
this.listenTo(this.channel, 'data', (payload) => {
|
|
711
|
+
this.collection.upsert(payload);
|
|
712
|
+
});
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
Use `pipeChannel` when every message should be upserted as-is. If you need to transform, filter, or route messages to different targets, use `listenTo` directly.
|
|
716
|
+
|
|
717
|
+
Returns an unsubscribe function, consistent with `subscribeTo` and `listenTo`.
|
|
718
|
+
|
|
719
|
+
### addCleanup(fn) — protected
|
|
720
|
+
|
|
721
|
+
Registers a cleanup function that runs on dispose. Use this for teardown that isn't covered by `subscribeTo` or `listenTo` (e.g., timers, intervals).
|
|
722
|
+
|
|
723
|
+
```typescript
|
|
724
|
+
protected onInit() {
|
|
725
|
+
const id = setInterval(() => this.poll(), 5000);
|
|
726
|
+
this.addCleanup(() => clearInterval(id));
|
|
727
|
+
}
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
---
|
|
731
|
+
|
|
732
|
+
## disposeSignal
|
|
733
|
+
|
|
734
|
+
A lazily-created `AbortSignal` that aborts when the ViewModel is disposed. Pass it to `fetch()` or any async API that accepts a signal.
|
|
735
|
+
|
|
736
|
+
```typescript
|
|
737
|
+
async load() {
|
|
738
|
+
const data = await this.service.getAll(this.disposeSignal);
|
|
739
|
+
this.collection.reset(data);
|
|
740
|
+
}
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
- **Lazy** — zero cost if never accessed. No AbortController allocated unless you read `disposeSignal`.
|
|
744
|
+
- **Stable** — returns the same signal on every access.
|
|
745
|
+
- **Aborted before `onDispose()` runs** — cleanup code can check it.
|
|
746
|
+
|
|
747
|
+
For per-call cancellation, compose signals:
|
|
748
|
+
|
|
749
|
+
```typescript
|
|
750
|
+
async loadRoom(roomId: string, callSignal: AbortSignal) {
|
|
751
|
+
const res = await fetch(`/api/rooms/${roomId}`, {
|
|
752
|
+
signal: AbortSignal.any([this.disposeSignal, callSignal]),
|
|
753
|
+
});
|
|
754
|
+
this.set({ messages: await res.json() });
|
|
755
|
+
}
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
---
|
|
759
|
+
|
|
760
|
+
## Reserved Keys
|
|
761
|
+
|
|
762
|
+
The following property names are reserved and cannot be overridden by subclasses:
|
|
763
|
+
|
|
764
|
+
- `async` — used by the async tracking proxy
|
|
765
|
+
- `subscribeAsync` — used by async state subscription
|
|
766
|
+
|
|
767
|
+
Attempting to define these as methods, getters, or class fields throws:
|
|
768
|
+
|
|
769
|
+
```
|
|
770
|
+
[mvc-kit] "async" is a reserved property on ViewModel and cannot be overridden.
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
The check runs in the constructor (prototype scan) and during `init()` (instance property scan).
|
|
774
|
+
|
|
775
|
+
---
|
|
776
|
+
|
|
777
|
+
## Dev Mode
|
|
778
|
+
|
|
779
|
+
When `__MVC_KIT_DEV__` is enabled, additional safety checks activate:
|
|
780
|
+
|
|
781
|
+
### set() inside a getter
|
|
782
|
+
|
|
783
|
+
Detects and blocks `set()` calls during getter evaluation. Logs an error and prevents the state mutation:
|
|
784
|
+
|
|
785
|
+
```
|
|
786
|
+
[mvc-kit] set() called inside a getter. Getters must be pure — they read state
|
|
787
|
+
and return a value. They must never call set(), which would cause an infinite
|
|
788
|
+
render loop. Move this logic to an action method.
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
### Method call after dispose
|
|
792
|
+
|
|
793
|
+
Wrapped methods log a warning and return `undefined`:
|
|
794
|
+
|
|
795
|
+
```
|
|
796
|
+
[mvc-kit] "load" called after dispose — ignored.
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
### Method call before init
|
|
800
|
+
|
|
801
|
+
Wrapped methods log a warning (but still execute):
|
|
802
|
+
|
|
803
|
+
```
|
|
804
|
+
[mvc-kit] "load" called before init(). Async tracking is active only after init().
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
### Ghost async operations
|
|
808
|
+
|
|
809
|
+
When a ViewModel is disposed with pending async calls, a delayed warning fires after `GHOST_TIMEOUT` (default 3000ms):
|
|
810
|
+
|
|
811
|
+
```
|
|
812
|
+
[mvc-kit] Ghost async operation detected: "load" had 1 pending call(s)
|
|
813
|
+
when the ViewModel was disposed. Consider using disposeSignal to cancel
|
|
814
|
+
in-flight work.
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
Override `static GHOST_TIMEOUT` on your subclass to change the delay:
|
|
818
|
+
|
|
819
|
+
```typescript
|
|
820
|
+
class MyVM extends ViewModel<State> {
|
|
821
|
+
static override GHOST_TIMEOUT = 5000;
|
|
822
|
+
}
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
### static DEFAULT_STATE
|
|
826
|
+
|
|
827
|
+
Declare `static DEFAULT_STATE` on singleton ViewModels so `singleton()` and `useSingleton()` can be called without constructor arguments. The value is used as the initial state when no args are passed:
|
|
828
|
+
|
|
829
|
+
```typescript
|
|
830
|
+
class AuthViewModel extends ViewModel<AuthState> {
|
|
831
|
+
static DEFAULT_STATE: AuthState = { user: null, isAuthenticated: false };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// No args needed — DEFAULT_STATE is used automatically
|
|
835
|
+
private auth = singleton(AuthViewModel);
|
|
836
|
+
const [state, vm] = useSingleton(AuthViewModel);
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
---
|
|
840
|
+
|
|
841
|
+
## React Integration
|
|
842
|
+
|
|
843
|
+
### useLocal
|
|
844
|
+
|
|
845
|
+
Creates a component-scoped ViewModel, auto-initialized and auto-disposed.
|
|
846
|
+
|
|
847
|
+
```tsx
|
|
848
|
+
function LocationsPage() {
|
|
849
|
+
const [state, vm] = useLocal(LocationsViewModel, {
|
|
850
|
+
items: [],
|
|
851
|
+
search: '',
|
|
852
|
+
typeFilter: 'all',
|
|
853
|
+
});
|
|
854
|
+
const { loading, error } = vm.async.load;
|
|
855
|
+
|
|
856
|
+
return (
|
|
857
|
+
<div>
|
|
858
|
+
<input value={state.search} onChange={e => vm.setSearch(e.target.value)} />
|
|
859
|
+
{loading && <Spinner />}
|
|
860
|
+
{error && <ErrorBanner message={error} />}
|
|
861
|
+
<LocationsTable locations={vm.filtered} />
|
|
862
|
+
</div>
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
**With deps:** Dispose and recreate when dependencies change.
|
|
868
|
+
|
|
869
|
+
```tsx
|
|
870
|
+
const [state, vm] = useLocal(LocationsViewModel, { userId, data: null }, [userId]);
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
**Factory overload:**
|
|
874
|
+
|
|
875
|
+
```tsx
|
|
876
|
+
const [state, vm] = useLocal(() => new ChatViewModel(roomId), [roomId]);
|
|
877
|
+
```
|
|
878
|
+
|
|
879
|
+
### useEvent
|
|
880
|
+
|
|
881
|
+
Subscribes to ViewModel events.
|
|
882
|
+
|
|
883
|
+
```tsx
|
|
884
|
+
useEvent(vm, 'saved', ({ id }) => {
|
|
885
|
+
toast.success(`Saved ${id}`);
|
|
886
|
+
});
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
### How Re-renders Work
|
|
890
|
+
|
|
891
|
+
`useLocal` uses `useSyncExternalStore` internally via two subscriptions:
|
|
892
|
+
|
|
893
|
+
1. **Combined subscription** — a single `useSyncExternalStore` with a version counter. Both `subscribe` and `subscribeAsync` (if present) increment the counter to trigger re-renders. `subscribable.state` is read directly in the render body.
|
|
894
|
+
|
|
895
|
+
This means components re-render when:
|
|
896
|
+
- State changes via `set()`
|
|
897
|
+
- A subscribable member notifies (Collection reset, Channel status change)
|
|
898
|
+
- An async method starts, completes, or fails
|
|
899
|
+
|
|
900
|
+
---
|
|
901
|
+
|
|
902
|
+
## Inheritance
|
|
903
|
+
|
|
904
|
+
### Getters
|
|
905
|
+
|
|
906
|
+
Getters from all levels of the class hierarchy are memoized. Most-derived wins:
|
|
907
|
+
|
|
908
|
+
```typescript
|
|
909
|
+
class ParentVM extends ViewModel<State> {
|
|
910
|
+
get label(): string { return 'parent'; }
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
class ChildVM extends ParentVM {
|
|
914
|
+
override get label(): string { return this.state.name; } // this version is memoized
|
|
915
|
+
}
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
### Methods
|
|
919
|
+
|
|
920
|
+
Methods from all levels are wrapped for async tracking. Most-derived wins:
|
|
921
|
+
|
|
922
|
+
```typescript
|
|
923
|
+
class ParentVM extends ViewModel<State> {
|
|
924
|
+
async load(): Promise<string> { return 'parent'; }
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
class ChildVM extends ParentVM {
|
|
928
|
+
async load(): Promise<string> { return 'child'; } // this version is tracked
|
|
929
|
+
}
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
### Multiple Instances
|
|
933
|
+
|
|
934
|
+
Two instances of the same ViewModel class have completely independent state, caches, and async tracking. They do not share anything.
|
|
935
|
+
|
|
936
|
+
---
|
|
937
|
+
|
|
938
|
+
## Section Order Convention
|
|
939
|
+
|
|
940
|
+
Organize every ViewModel consistently for scannability:
|
|
941
|
+
|
|
942
|
+
```typescript
|
|
943
|
+
export class LocationsViewModel extends ViewModel<State, Events> {
|
|
944
|
+
// --- Private fields ---
|
|
945
|
+
private service = singleton(LocationService);
|
|
946
|
+
private collection = singleton(LocationsCollection);
|
|
947
|
+
|
|
948
|
+
// --- Computed getters ---
|
|
949
|
+
get filtered(): Item[] { /* ... */ }
|
|
950
|
+
get total(): number { /* ... */ }
|
|
951
|
+
|
|
952
|
+
// --- Lifecycle ---
|
|
953
|
+
protected onInit() { /* ... */ }
|
|
954
|
+
|
|
955
|
+
// --- Actions ---
|
|
956
|
+
async load() { /* ... */ }
|
|
957
|
+
async refresh() { /* ... */ }
|
|
958
|
+
|
|
959
|
+
// --- Setters ---
|
|
960
|
+
setSearch(search: string) { this.set({ search }); }
|
|
961
|
+
setTypeFilter(typeFilter: State['typeFilter']) { this.set({ typeFilter }); }
|
|
962
|
+
}
|
|
963
|
+
```
|
|
964
|
+
|
|
965
|
+
**Private fields → Computed getters → Lifecycle → Actions → Setters.**
|
|
966
|
+
|
|
967
|
+
---
|
|
968
|
+
|
|
969
|
+
## Best Practices
|
|
970
|
+
|
|
971
|
+
**State holds only source-of-truth values.** User inputs and data from the server. Never derived values (use getters), never loading/error flags (use `vm.async`).
|
|
972
|
+
|
|
973
|
+
**Write the happy path for async methods.** No try/catch, no loading flags, no abort checks. The framework handles all of it.
|
|
974
|
+
|
|
975
|
+
```typescript
|
|
976
|
+
async load() {
|
|
977
|
+
const data = await this.service.getAll(this.disposeSignal);
|
|
978
|
+
this.collection.reset(data);
|
|
979
|
+
}
|
|
980
|
+
```
|
|
981
|
+
|
|
982
|
+
**Pass `disposeSignal` to every async call.** Ensures in-flight requests cancel on unmount.
|
|
983
|
+
|
|
984
|
+
**Use `subscribeTo` for Collection subscriptions.** It auto-cleans up on dispose. **Use `listenTo` for Channel/EventBus event subscriptions.** It auto-cleans up on both dispose and reset.
|
|
985
|
+
|
|
986
|
+
**Check `collection.length > 0` in `onInit` before fetching.** Another ViewModel may have already loaded the data (smart init pattern).
|
|
987
|
+
|
|
988
|
+
**Setters do one thing — update a single state value.** Derivation happens in getters, not in setters.
|
|
989
|
+
|
|
990
|
+
```typescript
|
|
991
|
+
setSearch(search: string) { this.set({ search }); }
|
|
992
|
+
```
|
|
993
|
+
|
|
994
|
+
**Methods are auto-bound.** All public methods are bound to the instance in the constructor, so they can be passed point-free as callbacks without losing `this` context. Both styles are equivalent:
|
|
995
|
+
|
|
996
|
+
```tsx
|
|
997
|
+
// Arrow wrapper (always worked)
|
|
998
|
+
<SearchBox onChange={v => vm.setSearch(v)} />
|
|
999
|
+
|
|
1000
|
+
// Point-free (works because methods are auto-bound)
|
|
1001
|
+
<SearchBox onChange={vm.setSearch} />
|
|
1002
|
+
```
|
|
1003
|
+
|
|
1004
|
+
**Re-throw errors in explicit try/catch blocks.** So async tracking still captures them. Use `isAbortError()` to guard side effects on shared state (like Collection rollbacks). `set()` and `emit()` don't need the guard — they're already no-ops after dispose:
|
|
1005
|
+
|
|
1006
|
+
```typescript
|
|
1007
|
+
// Shared-state side effects — guard needed:
|
|
1008
|
+
catch (e) {
|
|
1009
|
+
if (!isAbortError(e)) rollback();
|
|
1010
|
+
throw e;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Only set()/emit() — no guard needed (no-ops after dispose):
|
|
1014
|
+
catch (e) {
|
|
1015
|
+
this.emit('error', { message: 'Failed' });
|
|
1016
|
+
throw e;
|
|
1017
|
+
}
|
|
1018
|
+
```
|
|
1019
|
+
|
|
1020
|
+
**Use `reset()` for form resets or route-param changes.** It tears down and re-initializes without unmounting the component.
|
|
1021
|
+
|
|
1022
|
+
---
|
|
1023
|
+
|
|
1024
|
+
## Anti-Patterns
|
|
1025
|
+
|
|
1026
|
+
### Loading/error in state
|
|
1027
|
+
|
|
1028
|
+
```typescript
|
|
1029
|
+
// Bad: async tracking handles this
|
|
1030
|
+
interface State {
|
|
1031
|
+
loading: boolean;
|
|
1032
|
+
error: string | null;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Good: read from vm.async.load
|
|
1036
|
+
const { loading, error } = vm.async.load;
|
|
1037
|
+
```
|
|
1038
|
+
|
|
1039
|
+
### Derived values in state
|
|
1040
|
+
|
|
1041
|
+
```typescript
|
|
1042
|
+
// Bad: will desync
|
|
1043
|
+
this.set({ filtered: items.filter(...) });
|
|
1044
|
+
|
|
1045
|
+
// Good: getter recomputes automatically
|
|
1046
|
+
get filtered() { return this.state.items.filter(...); }
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
### set() inside a getter
|
|
1050
|
+
|
|
1051
|
+
```typescript
|
|
1052
|
+
// Bad: infinite loop (blocked in DEV mode)
|
|
1053
|
+
get broken() {
|
|
1054
|
+
this.set({ count: 999 });
|
|
1055
|
+
return 'bad';
|
|
1056
|
+
}
|
|
1057
|
+
```
|
|
1058
|
+
|
|
1059
|
+
### Manual try/catch for standard loads
|
|
1060
|
+
|
|
1061
|
+
```typescript
|
|
1062
|
+
// Bad: unnecessary boilerplate
|
|
1063
|
+
async load() {
|
|
1064
|
+
this.set({ loading: true });
|
|
1065
|
+
try {
|
|
1066
|
+
const data = await this.service.getAll(this.disposeSignal);
|
|
1067
|
+
this.set({ items: data, loading: false });
|
|
1068
|
+
} catch (e) { /* ... */ }
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// Good: write the happy path
|
|
1072
|
+
async load() {
|
|
1073
|
+
const data = await this.service.getAll(this.disposeSignal);
|
|
1074
|
+
this.collection.reset(data);
|
|
1075
|
+
}
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
### Swallowing errors without re-throwing
|
|
1079
|
+
|
|
1080
|
+
```typescript
|
|
1081
|
+
// Bad: async tracking won't capture the error
|
|
1082
|
+
catch (e) {
|
|
1083
|
+
this.emit('error', { message: 'Failed' });
|
|
1084
|
+
// error swallowed — vm.async.save.error stays null
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Good: re-throw so tracking works
|
|
1088
|
+
catch (e) {
|
|
1089
|
+
this.emit('error', { message: 'Failed' });
|
|
1090
|
+
throw e;
|
|
1091
|
+
}
|
|
1092
|
+
```
|
|
1093
|
+
|
|
1094
|
+
Note: `emit()` is a no-op after dispose, so no `isAbortError()` guard is needed here. Use `isAbortError()` only when the catch block has side effects on shared state (like rolling back optimistic updates on a singleton Collection).
|
|
1095
|
+
|
|
1096
|
+
### Two ViewModels in one component
|
|
1097
|
+
|
|
1098
|
+
```tsx
|
|
1099
|
+
// Bad: split into two components
|
|
1100
|
+
const [s1, vm1] = useLocal(UsersViewModel, { ... });
|
|
1101
|
+
const [s2, vm2] = useLocal(OnDutyViewModel, { ... });
|
|
1102
|
+
```
|
|
1103
|
+
|
|
1104
|
+
### Fetching in useEffect
|
|
1105
|
+
|
|
1106
|
+
```tsx
|
|
1107
|
+
// Bad: ViewModel handles its own initialization
|
|
1108
|
+
useEffect(() => { vm.load(); }, []);
|
|
1109
|
+
|
|
1110
|
+
// Good: onInit() handles it automatically via useLocal
|
|
1111
|
+
```
|