mvc-kit 2.2.1 → 2.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -9
- package/agent-config/claude-code/agents/mvc-kit-architect.md +2 -3
- package/agent-config/claude-code/skills/guide/SKILL.md +2 -2
- package/agent-config/claude-code/skills/guide/anti-patterns.md +44 -7
- package/agent-config/claude-code/skills/guide/api-reference.md +5 -4
- package/agent-config/claude-code/skills/guide/patterns.md +22 -18
- package/agent-config/claude-code/skills/review/checklist.md +2 -2
- package/agent-config/claude-code/skills/scaffold/SKILL.md +1 -1
- package/agent-config/claude-code/skills/scaffold/templates/collection.md +7 -14
- package/agent-config/claude-code/skills/scaffold/templates/viewmodel.md +13 -42
- package/agent-config/copilot/copilot-instructions.md +18 -18
- package/agent-config/cursor/cursorrules +18 -18
- package/dist/Channel.d.ts +29 -0
- package/dist/Channel.d.ts.map +1 -1
- package/dist/Collection.d.ts +16 -1
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Controller.d.ts +9 -0
- package/dist/Controller.d.ts.map +1 -1
- package/dist/EventBus.d.ts +5 -0
- package/dist/EventBus.d.ts.map +1 -1
- package/dist/Model.d.ts +16 -0
- package/dist/Model.d.ts.map +1 -1
- package/dist/Service.d.ts +8 -0
- package/dist/Service.d.ts.map +1 -1
- package/dist/ViewModel.d.ts +35 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +1 -1
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +226 -111
- package/dist/mvc-kit.js.map +1 -1
- package/dist/react/provider.d.ts +1 -0
- package/dist/react/provider.d.ts.map +1 -1
- package/dist/react/use-model.d.ts +2 -0
- package/dist/react/use-model.d.ts.map +1 -1
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +1 -1
- package/dist/react.js.map +1 -1
- package/dist/{singleton-C8_FRbA7.js → singleton-CaEXSbYg.js} +5 -1
- package/dist/singleton-CaEXSbYg.js.map +1 -0
- package/dist/singleton-L-u2W_lX.cjs.map +1 -1
- package/dist/singleton.d.ts +10 -0
- package/dist/singleton.d.ts.map +1 -1
- package/mvc-kit-logo.jpg +0 -0
- package/package.json +2 -1
- package/dist/singleton-C8_FRbA7.js.map +0 -1
package/README.md
CHANGED
|
@@ -131,6 +131,7 @@ const todos = new Collection<Todo>();
|
|
|
131
131
|
|
|
132
132
|
// CRUD (triggers re-renders)
|
|
133
133
|
todos.add({ id: '1', text: 'Learn mvc-kit', done: false });
|
|
134
|
+
todos.upsert({ id: '1', text: 'Updated', done: true }); // Add-or-replace by ID
|
|
134
135
|
todos.update('1', { done: true });
|
|
135
136
|
todos.remove('1');
|
|
136
137
|
todos.reset([...]); // Replace all
|
|
@@ -325,7 +326,9 @@ vm.fetchUsers(); // loading: true (count: 2)
|
|
|
325
326
|
#### Error handling
|
|
326
327
|
|
|
327
328
|
- **Normal errors** are captured in `TaskState.error` (as a string message) AND re-thrown — standard Promise rejection behavior is preserved
|
|
328
|
-
- **AbortErrors** are silently swallowed — not captured in `TaskState.error`, not re-thrown
|
|
329
|
+
- **AbortErrors** are silently swallowed by the wrapper — not captured in `TaskState.error`, not re-thrown from the outer promise
|
|
330
|
+
|
|
331
|
+
For methods without try/catch, AbortError handling is fully automatic. For methods with explicit try/catch, see [Error Utilities](#error-utilities) for when `isAbortError()` is needed.
|
|
329
332
|
|
|
330
333
|
#### Sync method pruning
|
|
331
334
|
|
|
@@ -364,14 +367,16 @@ import { isAbortError, classifyError, HttpError } from 'mvc-kit';
|
|
|
364
367
|
// In services — throw typed HTTP errors
|
|
365
368
|
if (!res.ok) throw new HttpError(res.status, res.statusText);
|
|
366
369
|
|
|
367
|
-
// In
|
|
368
|
-
if (isAbortError(e))
|
|
370
|
+
// In ViewModel catch blocks — guard shared-state side effects on abort
|
|
371
|
+
if (!isAbortError(e)) rollback();
|
|
369
372
|
|
|
370
373
|
// Classify any error into a canonical shape
|
|
371
374
|
const appError = classifyError(error);
|
|
372
375
|
// appError.code: 'unauthorized' | 'network' | 'timeout' | 'abort' | ...
|
|
373
376
|
```
|
|
374
377
|
|
|
378
|
+
**When to use `isAbortError()`:** The async tracking wrapper swallows AbortErrors at the outer promise level, but your catch blocks do receive them. Use `isAbortError()` only when the catch block has side effects on shared state (like rolling back optimistic updates on a singleton Collection). You don't need it for `set()` or `emit()` (both are no-ops after dispose), and you never need it in methods without try/catch.
|
|
379
|
+
|
|
375
380
|
## Signal & Cleanup
|
|
376
381
|
|
|
377
382
|
Every class in mvc-kit has a built-in `AbortSignal` and cleanup registration system. This eliminates the need to manually track timers, subscriptions, and in-flight requests.
|
|
@@ -397,13 +402,13 @@ class ChatViewModel extends ViewModel<{ messages: Message[] }> {
|
|
|
397
402
|
|
|
398
403
|
### `subscribeTo(source, listener)` (protected)
|
|
399
404
|
|
|
400
|
-
Subscribe to a Subscribable and auto-unsubscribe on dispose. Available on ViewModel, Model, and Controller.
|
|
405
|
+
Subscribe to a Subscribable and auto-unsubscribe on dispose. Available on ViewModel, Model, and Controller. Use for **imperative reactions** — for deriving values from collections, use getters instead (auto-tracking handles reactivity).
|
|
401
406
|
|
|
402
407
|
```typescript
|
|
403
|
-
class
|
|
408
|
+
class ChatViewModel extends ViewModel<State> {
|
|
404
409
|
protected onInit() {
|
|
405
|
-
//
|
|
406
|
-
this.subscribeTo(this.
|
|
410
|
+
// Imperative reaction: play sound on new messages
|
|
411
|
+
this.subscribeTo(this.messagesCollection, () => this.playNotificationSound());
|
|
407
412
|
}
|
|
408
413
|
}
|
|
409
414
|
```
|
|
@@ -685,7 +690,7 @@ function App() {
|
|
|
685
690
|
|--------|-------------|
|
|
686
691
|
| `AppError` (type) | Canonical error shape with typed `code` field |
|
|
687
692
|
| `HttpError` | Typed HTTP error class for services to throw |
|
|
688
|
-
| `isAbortError(error)` | Guard for AbortError
|
|
693
|
+
| `isAbortError(error)` | Guard for AbortError — use in catch blocks with shared-state side effects |
|
|
689
694
|
| `classifyError(error)` | Maps raw errors → `AppError` |
|
|
690
695
|
|
|
691
696
|
### React Hooks
|
|
@@ -722,7 +727,7 @@ function App() {
|
|
|
722
727
|
- ViewModel has built-in typed events via optional second generic `E` — `events` getter, `emit()` method
|
|
723
728
|
- After `init()`, all subclass methods are wrapped for automatic async tracking; `vm.async.methodName` returns `TaskState`
|
|
724
729
|
- Sync methods are auto-pruned on first call — zero overhead after detection
|
|
725
|
-
- Async errors are re-thrown (preserves standard Promise rejection); AbortErrors are silently swallowed
|
|
730
|
+
- Async errors are re-thrown (preserves standard Promise rejection); AbortErrors are silently swallowed by the wrapper (but internal catch blocks do receive them — use `isAbortError()` to guard shared-state side effects like Collection rollbacks)
|
|
726
731
|
- `async` and `subscribeAsync` are reserved property names on ViewModel
|
|
727
732
|
- React hooks (`useLocal`, `useModel`, `useSingleton`) auto-call `init()` after mount
|
|
728
733
|
- `singleton()` does **not** auto-call `init()` — call it manually outside React
|
|
@@ -52,10 +52,9 @@ When asked to plan a feature:
|
|
|
52
52
|
|
|
53
53
|
- State holds only source-of-truth values. Derived values are `get` accessors. Async status comes from `vm.async.methodName`.
|
|
54
54
|
- One ViewModel per component via `useLocal`. No `useEffect` for data loading.
|
|
55
|
-
- Collections are encapsulated by ViewModels. Components never import Collections.
|
|
55
|
+
- Collections are encapsulated by ViewModels. Getters read from collections directly — auto-tracking handles reactivity. Components never import Collections.
|
|
56
56
|
- Services are stateless, accept `AbortSignal`, throw `HttpError`.
|
|
57
|
-
- `onInit()` handles data loading
|
|
58
|
-
- Use `subscribeTo()` for Collection subscriptions (auto-cleanup).
|
|
57
|
+
- `onInit()` handles data loading. Use `subscribeTo()` only for imperative side effects, not for deriving values.
|
|
59
58
|
- ViewModel section order: Private fields → Computed getters → Lifecycle → Actions → Setters.
|
|
60
59
|
- Pass `this.disposeSignal` to every async call.
|
|
61
60
|
|
|
@@ -29,13 +29,13 @@ You are assisting a developer using **mvc-kit**, a zero-dependency TypeScript-fi
|
|
|
29
29
|
|
|
30
30
|
3. **Components are declarative.** Read `state.x` for raw values, `vm.x` for computed, `vm.async.x` for loading/error. Call `vm.method()` for actions.
|
|
31
31
|
|
|
32
|
-
4. **Collections are encapsulated.**
|
|
32
|
+
4. **Collections are encapsulated.** ViewModel getters read from collections directly — auto-tracking handles reactivity. Use `subscribeTo()` only for imperative side effects. Components never import Collections.
|
|
33
33
|
|
|
34
34
|
5. **Services are stateless.** Accept `AbortSignal`, throw `HttpError`, no knowledge of ViewModels or Collections.
|
|
35
35
|
|
|
36
36
|
6. **Lifecycle**: `construct → init() → use → dispose()`. React hooks auto-call `init()`. Use `onInit()` for data loading.
|
|
37
37
|
|
|
38
|
-
7. **Async tracking is automatic.** After `init()`, all async methods are tracked. `vm.async.methodName` returns `{ loading, error, errorCode }`. AbortErrors are silently swallowed. Other errors are captured AND re-thrown.
|
|
38
|
+
7. **Async tracking is automatic.** After `init()`, all async methods are tracked. `vm.async.methodName` returns `{ loading, error, errorCode }`. AbortErrors are silently swallowed by the wrapper. Other errors are captured AND re-thrown. Internal catch blocks do receive AbortErrors — use `isAbortError()` only to guard shared-state side effects (like Collection rollbacks). `set()` and `emit()` don't need the guard (no-ops after dispose).
|
|
39
39
|
|
|
40
40
|
8. **Pass `this.disposeSignal`** to every async call for automatic cancellation on unmount.
|
|
41
41
|
|
|
@@ -9,16 +9,37 @@ Reject these patterns. Each entry shows the bad pattern and the correct alternat
|
|
|
9
9
|
```typescript
|
|
10
10
|
// BAD
|
|
11
11
|
interface State {
|
|
12
|
-
items: Item[];
|
|
13
12
|
loading: boolean;
|
|
14
13
|
error: string | null;
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
// GOOD — async tracking handles it
|
|
17
|
+
// Read: vm.async.load.loading, vm.async.load.error
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 1b. Mirroring Collection Data into State
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
// BAD — subscribeTo + set() to mirror collection data
|
|
18
26
|
interface State {
|
|
19
27
|
items: Item[];
|
|
28
|
+
search: string;
|
|
29
|
+
}
|
|
30
|
+
protected onInit() {
|
|
31
|
+
this.subscribeTo(this.collection, () => {
|
|
32
|
+
this.set({ items: this.collection.items });
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// GOOD — getter reads from collection directly, auto-tracked
|
|
37
|
+
interface State {
|
|
38
|
+
search: string;
|
|
39
|
+
}
|
|
40
|
+
get items(): Item[] {
|
|
41
|
+
return this.collection.items as Item[];
|
|
20
42
|
}
|
|
21
|
-
// Read: vm.async.load.loading, vm.async.load.error
|
|
22
43
|
```
|
|
23
44
|
|
|
24
45
|
---
|
|
@@ -175,9 +196,7 @@ async save() {
|
|
|
175
196
|
await this.service.save(this.state.draft, this.disposeSignal);
|
|
176
197
|
this.emit('saved', { id: this.state.draft.id });
|
|
177
198
|
} catch (e) {
|
|
178
|
-
|
|
179
|
-
this.emit('error', { message: classifyError(e).message });
|
|
180
|
-
}
|
|
199
|
+
this.emit('error', { message: classifyError(e).message }); // no isAbortError guard needed — emit() is a no-op after dispose
|
|
181
200
|
throw e; // async tracking captures the error
|
|
182
201
|
}
|
|
183
202
|
}
|
|
@@ -243,7 +262,7 @@ async toggleStatus(id: string) {
|
|
|
243
262
|
try {
|
|
244
263
|
await this.service.update(id, { status: 'done' }, this.disposeSignal);
|
|
245
264
|
} catch (e) {
|
|
246
|
-
if (!isAbortError(e)) rollback();
|
|
265
|
+
if (!isAbortError(e)) rollback(); // guard needed — rollback affects shared Collection
|
|
247
266
|
throw e;
|
|
248
267
|
}
|
|
249
268
|
}
|
|
@@ -304,7 +323,25 @@ function UsersPage() {
|
|
|
304
323
|
|
|
305
324
|
---
|
|
306
325
|
|
|
307
|
-
## 15.
|
|
326
|
+
## 15. Using reset() for Paginated/Incremental Loads
|
|
327
|
+
|
|
328
|
+
```typescript
|
|
329
|
+
// BAD — reset() destroys data other ViewModels depend on
|
|
330
|
+
async loadPage(page: number) {
|
|
331
|
+
const data = await this.service.getPage(page, this.disposeSignal);
|
|
332
|
+
this.collection.reset(data); // wipes previous pages!
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// GOOD — upsert() accumulates data, replacing stale items and adding new ones
|
|
336
|
+
async loadPage(page: number) {
|
|
337
|
+
const data = await this.service.getPage(page, this.disposeSignal);
|
|
338
|
+
this.collection.upsert(...data);
|
|
339
|
+
}
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
## 16. Missing disposeSignal on Async Calls
|
|
308
345
|
|
|
309
346
|
```typescript
|
|
310
347
|
// BAD — no cancellation on unmount
|
|
@@ -108,10 +108,11 @@ Same as ViewModel: `onInit()`, `onSet()`, `onDispose()`, `disposeSignal`, `subsc
|
|
|
108
108
|
Reactive typed array with CRUD and query methods. Items must have an `id: string` field.
|
|
109
109
|
|
|
110
110
|
### CRUD (triggers notifications)
|
|
111
|
-
- `add(
|
|
111
|
+
- `add(...items: T[]): void` — Append items. Skips existing IDs.
|
|
112
|
+
- `upsert(...items: T[]): void` — Add-or-replace by ID. Existing replaced in-place; new appended. Ideal for paginated loads.
|
|
112
113
|
- `update(id: string, partial: Partial<T>): void`
|
|
113
|
-
- `remove(
|
|
114
|
-
- `reset(items: T[]): void` — Replace all items.
|
|
114
|
+
- `remove(...ids: string[]): void`
|
|
115
|
+
- `reset(items: T[]): void` — Replace all items. Use for full loads; prefer `upsert()` for incremental.
|
|
115
116
|
- `clear(): void`
|
|
116
117
|
|
|
117
118
|
### Query (pure, no notifications)
|
|
@@ -215,7 +216,7 @@ teardownAll() // Dispose all (use in test beforeEach)
|
|
|
215
216
|
## Error Utilities
|
|
216
217
|
|
|
217
218
|
- `HttpError(status: number, statusText: string)` — Throw from services.
|
|
218
|
-
- `isAbortError(error: unknown): boolean` — Guard for AbortError.
|
|
219
|
+
- `isAbortError(error: unknown): boolean` — Guard for AbortError. Only needed in catch blocks with shared-state side effects (e.g., Collection rollbacks). Not needed for `set()`/`emit()` (no-ops after dispose) or methods without try/catch.
|
|
219
220
|
- `classifyError(error: unknown): AppError` — Returns `{ code, message, status? }`.
|
|
220
221
|
- Codes: `'unauthorized'`, `'forbidden'`, `'not_found'`, `'network'`, `'timeout'`, `'abort'`, `'server_error'`, `'unknown'`
|
|
221
222
|
|
|
@@ -69,28 +69,27 @@ The setter changes state → React re-renders → getters recompute. No manual r
|
|
|
69
69
|
|
|
70
70
|
## Encapsulating Collections
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
Getters read directly from collection members — auto-tracking handles reactivity. Components never see Collections.
|
|
73
73
|
|
|
74
74
|
```typescript
|
|
75
75
|
class LocationsViewModel extends ViewModel<State> {
|
|
76
|
-
|
|
76
|
+
collection = singleton(LocationsCollection);
|
|
77
77
|
private service = singleton(LocationService);
|
|
78
78
|
|
|
79
|
+
// Getter reads from collection — auto-tracked
|
|
80
|
+
get items(): LocationState[] {
|
|
81
|
+
return this.collection.items as LocationState[];
|
|
82
|
+
}
|
|
83
|
+
|
|
79
84
|
protected onInit() {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
// Smart init: use cached data or fetch fresh
|
|
85
|
-
if (this.collection.length > 0) {
|
|
86
|
-
this.set({ items: this.collection.items });
|
|
87
|
-
} else {
|
|
88
|
-
this.load();
|
|
89
|
-
}
|
|
85
|
+
// Smart init: skip fetch if data already loaded
|
|
86
|
+
if (this.collection.length === 0) this.load();
|
|
90
87
|
}
|
|
91
88
|
}
|
|
92
89
|
```
|
|
93
90
|
|
|
91
|
+
Use `subscribeTo()` only for imperative side effects (e.g. play a sound on new messages), not for deriving values.
|
|
92
|
+
|
|
94
93
|
---
|
|
95
94
|
|
|
96
95
|
## Async Methods — Happy Path Only
|
|
@@ -100,22 +99,27 @@ async load() {
|
|
|
100
99
|
const data = await this.service.getAll(this.disposeSignal);
|
|
101
100
|
this.collection.reset(data);
|
|
102
101
|
}
|
|
102
|
+
|
|
103
|
+
// For paginated/incremental loads, use upsert() to accumulate data:
|
|
104
|
+
async loadPage(page: number) {
|
|
105
|
+
const data = await this.service.getPage(page, this.disposeSignal);
|
|
106
|
+
this.collection.upsert(...data);
|
|
107
|
+
}
|
|
103
108
|
```
|
|
104
109
|
|
|
105
110
|
No try/catch. No `set({ loading: true })`. No AbortError check. The framework handles it.
|
|
106
111
|
|
|
107
|
-
**Only add try/catch** for imperative events on error:
|
|
112
|
+
**Only add try/catch** for imperative events on error or optimistic rollbacks:
|
|
108
113
|
|
|
109
114
|
```typescript
|
|
115
|
+
// Imperative events — no isAbortError guard needed (emit is a no-op after dispose)
|
|
110
116
|
async save() {
|
|
111
117
|
try {
|
|
112
118
|
const result = await this.service.save(this.state.draft, this.disposeSignal);
|
|
113
119
|
this.collection.update(result.id, result);
|
|
114
120
|
this.emit('saved', { id: result.id });
|
|
115
121
|
} catch (e) {
|
|
116
|
-
|
|
117
|
-
this.emit('error', { message: classifyError(e).message });
|
|
118
|
-
}
|
|
122
|
+
this.emit('error', { message: classifyError(e).message });
|
|
119
123
|
throw e; // MUST re-throw so async tracking captures it
|
|
120
124
|
}
|
|
121
125
|
}
|
|
@@ -221,7 +225,7 @@ class UserService extends Service {
|
|
|
221
225
|
class UsersCollection extends Collection<UserState> {}
|
|
222
226
|
```
|
|
223
227
|
|
|
224
|
-
Thin subclass for singleton identity. No custom methods — query logic goes in ViewModel getters.
|
|
228
|
+
Thin subclass for singleton identity. No custom methods — query logic goes in ViewModel getters. Use `reset()` for full loads, `upsert()` for paginated/incremental loads, and `add`/`update`/`remove` for granular mutations.
|
|
225
229
|
|
|
226
230
|
---
|
|
227
231
|
|
|
@@ -235,7 +239,7 @@ async toggleStatus(id: string) {
|
|
|
235
239
|
try {
|
|
236
240
|
await this.service.update(id, { status: 'done' }, this.disposeSignal);
|
|
237
241
|
} catch (e) {
|
|
238
|
-
if (!isAbortError(e)) rollback();
|
|
242
|
+
if (!isAbortError(e)) rollback(); // guard needed — rollback affects shared Collection
|
|
239
243
|
throw e; // re-throw for async tracking
|
|
240
244
|
}
|
|
241
245
|
}
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
2. **No derived state in State** — State must not contain values computable from other state (filtered lists, counts, flags). Use getters.
|
|
8
8
|
3. **No `set()` inside getters** — Creates infinite loop. Derived values must be pure computations.
|
|
9
9
|
4. **`disposeSignal` on async calls** — Every `fetch()`, service call, or async operation must receive `this.disposeSignal` or a composed signal.
|
|
10
|
-
5. **Re-throw in try/catch** — Any explicit try/catch must re-throw the error so async tracking captures it.
|
|
10
|
+
5. **Re-throw in try/catch** — Any explicit try/catch must re-throw the error so async tracking captures it. Use `isAbortError()` only to guard shared-state side effects (Collection rollbacks). Not needed for `set()`/`emit()` (no-ops after dispose).
|
|
11
11
|
|
|
12
12
|
### Warning
|
|
13
13
|
6. **Section order** — Must follow: Private fields → Computed getters → Lifecycle → Actions → Setters.
|
|
14
14
|
7. **No two-step setters** — Setters should be one-liners (`this.set({ x })`). No manual refilter/rederive calls after `set()`.
|
|
15
15
|
8. **Smart init pattern** — When subscribing to a Collection, check `collection.length > 0` before fetching.
|
|
16
|
-
9. **
|
|
16
|
+
9. **Getters read from collections** — Collection data should be accessed via getters, not mirrored into state with `subscribeTo()` + `set()`. Use `subscribeTo()` only for imperative side effects.
|
|
17
17
|
10. **Use `collection.optimistic()`** — No manual snapshot/restore for optimistic updates.
|
|
18
18
|
11. **Singleton resolution as property** — Dependencies resolved via `private service = singleton(MyService)`, not in `onInit()`.
|
|
19
19
|
|
|
@@ -47,6 +47,6 @@ Parse `$ARGUMENTS` as `<type> <Name>` (case-insensitive type, PascalCase Name).
|
|
|
47
47
|
- Services are stateless, accept `AbortSignal`, throw `HttpError`.
|
|
48
48
|
- Collections are thin subclasses — no custom methods.
|
|
49
49
|
- Use `singleton()` for dependency resolution in ViewModels.
|
|
50
|
-
- Use `subscribeTo()` for
|
|
50
|
+
- Getters read from collections directly — auto-tracking handles reactivity. Use `subscribeTo()` only for imperative side effects.
|
|
51
51
|
- Pass `this.disposeSignal` to every async call.
|
|
52
52
|
- Test files use `teardownAll()` in `beforeEach`.
|
|
@@ -22,23 +22,16 @@ import { ViewModel, singleton } from 'mvc-kit';
|
|
|
22
22
|
import { {{Name}}Collection } from '../collections/{{Name}}Collection';
|
|
23
23
|
import type { {{Name}}Item } from '../collections/{{Name}}Collection';
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
25
|
+
class {{Name}}ViewModel extends ViewModel<{ search: string }> {
|
|
26
|
+
collection = singleton({{Name}}Collection);
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
// Getter reads from collection — auto-tracked
|
|
29
|
+
get items(): {{Name}}Item[] {
|
|
30
|
+
return this.collection.items as {{Name}}Item[];
|
|
31
|
+
}
|
|
31
32
|
|
|
32
33
|
protected onInit() {
|
|
33
|
-
|
|
34
|
-
this.set({ items: this.collection.items });
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
if (this.collection.length > 0) {
|
|
38
|
-
this.set({ items: this.collection.items });
|
|
39
|
-
} else {
|
|
40
|
-
this.load();
|
|
41
|
-
}
|
|
34
|
+
if (this.collection.length === 0) this.load();
|
|
42
35
|
}
|
|
43
36
|
|
|
44
37
|
async load() {
|
|
@@ -8,7 +8,6 @@ import { ViewModel, singleton } from 'mvc-kit';
|
|
|
8
8
|
// import { {{Name}}Collection } from '../collections/{{Name}}Collection';
|
|
9
9
|
|
|
10
10
|
interface {{Name}}State {
|
|
11
|
-
items: any[]; // TODO: Replace with your item type
|
|
12
11
|
search: string;
|
|
13
12
|
}
|
|
14
13
|
|
|
@@ -20,11 +19,18 @@ interface {{Name}}State {
|
|
|
20
19
|
export class {{Name}}ViewModel extends ViewModel<{{Name}}State> {
|
|
21
20
|
// --- Private fields ---
|
|
22
21
|
// private service = singleton({{Name}}Service);
|
|
23
|
-
//
|
|
22
|
+
// collection = singleton({{Name}}Collection);
|
|
24
23
|
|
|
25
24
|
// --- Computed getters ---
|
|
25
|
+
// Getter reads from collection — auto-tracked
|
|
26
|
+
// get items(): any[] {
|
|
27
|
+
// return this.collection.items;
|
|
28
|
+
// }
|
|
29
|
+
|
|
26
30
|
get filtered(): any[] {
|
|
27
|
-
const {
|
|
31
|
+
const { search } = this.state;
|
|
32
|
+
// TODO: read from this.items (collection getter) instead of hardcoded array
|
|
33
|
+
const items: any[] = [];
|
|
28
34
|
if (!search) return items;
|
|
29
35
|
const q = search.toLowerCase();
|
|
30
36
|
return items.filter(item =>
|
|
@@ -33,7 +39,7 @@ export class {{Name}}ViewModel extends ViewModel<{{Name}}State> {
|
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
get total(): number {
|
|
36
|
-
return this.
|
|
42
|
+
return this.filtered.length;
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
get hasResults(): boolean {
|
|
@@ -42,17 +48,8 @@ export class {{Name}}ViewModel extends ViewModel<{{Name}}State> {
|
|
|
42
48
|
|
|
43
49
|
// --- Lifecycle ---
|
|
44
50
|
protected onInit() {
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
// this.set({ items: this.collection.items });
|
|
48
|
-
// });
|
|
49
|
-
|
|
50
|
-
// Smart init: use cached data or fetch fresh
|
|
51
|
-
// if (this.collection.length > 0) {
|
|
52
|
-
// this.set({ items: this.collection.items });
|
|
53
|
-
// } else {
|
|
54
|
-
// this.load();
|
|
55
|
-
// }
|
|
51
|
+
// Smart init: skip fetch if data already loaded
|
|
52
|
+
// if (this.collection.length === 0) this.load();
|
|
56
53
|
|
|
57
54
|
this.load();
|
|
58
55
|
}
|
|
@@ -79,9 +76,8 @@ import { {{Name}}ViewModel } from './{{Name}}ViewModel';
|
|
|
79
76
|
beforeEach(() => teardownAll());
|
|
80
77
|
|
|
81
78
|
describe('{{Name}}ViewModel', () => {
|
|
82
|
-
function create(overrides: Partial<{
|
|
79
|
+
function create(overrides: Partial<{ search: string }> = {}) {
|
|
83
80
|
return new {{Name}}ViewModel({
|
|
84
|
-
items: [],
|
|
85
81
|
search: '',
|
|
86
82
|
...overrides,
|
|
87
83
|
});
|
|
@@ -89,7 +85,6 @@ describe('{{Name}}ViewModel', () => {
|
|
|
89
85
|
|
|
90
86
|
test('initializes with default state', () => {
|
|
91
87
|
const vm = create();
|
|
92
|
-
expect(vm.state.items).toEqual([]);
|
|
93
88
|
expect(vm.state.search).toBe('');
|
|
94
89
|
vm.dispose();
|
|
95
90
|
});
|
|
@@ -100,29 +95,5 @@ describe('{{Name}}ViewModel', () => {
|
|
|
100
95
|
expect(vm.state.search).toBe('test');
|
|
101
96
|
vm.dispose();
|
|
102
97
|
});
|
|
103
|
-
|
|
104
|
-
test('filtered getter applies search', () => {
|
|
105
|
-
const vm = create({
|
|
106
|
-
items: [
|
|
107
|
-
{ id: '1', name: 'Alpha' },
|
|
108
|
-
{ id: '2', name: 'Beta' },
|
|
109
|
-
],
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
expect(vm.filtered).toHaveLength(2);
|
|
113
|
-
|
|
114
|
-
vm.setSearch('alpha');
|
|
115
|
-
expect(vm.filtered).toHaveLength(1);
|
|
116
|
-
|
|
117
|
-
vm.dispose();
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test('total returns item count', () => {
|
|
121
|
-
const vm = create({
|
|
122
|
-
items: [{ id: '1' }, { id: '2' }, { id: '3' }],
|
|
123
|
-
});
|
|
124
|
-
expect(vm.total).toBe(3);
|
|
125
|
-
vm.dispose();
|
|
126
|
-
});
|
|
127
98
|
});
|
|
128
99
|
```
|
|
@@ -30,7 +30,7 @@ import { useLocal, useSingleton, useInstance, useModel, useField, useEvent, useE
|
|
|
30
30
|
4. **One ViewModel per component** via `useLocal`. No `useEffect` for data loading — use `onInit()`.
|
|
31
31
|
5. **No `useState`/`useMemo`/`useCallback`** — the ViewModel is the hook.
|
|
32
32
|
6. **Components are declarative.** Read `state.x` for raw, `vm.x` for computed, `vm.async.x` for loading/error.
|
|
33
|
-
7. **Collections are encapsulated.**
|
|
33
|
+
7. **Collections are encapsulated.** ViewModel getters read from collections directly — auto-tracking handles reactivity. Use `subscribeTo()` only for imperative side effects. Components never import Collections.
|
|
34
34
|
8. **Services are stateless.** Accept `AbortSignal`, throw `HttpError`, no knowledge of ViewModels.
|
|
35
35
|
9. **Pass `this.disposeSignal`** to every async call.
|
|
36
36
|
10. **Lifecycle**: `construct → init() → use → dispose()`. Hooks auto-call `init()`.
|
|
@@ -39,35 +39,31 @@ import { useLocal, useSingleton, useInstance, useModel, useField, useEvent, useE
|
|
|
39
39
|
|
|
40
40
|
```typescript
|
|
41
41
|
interface ItemState {
|
|
42
|
-
items: Item[];
|
|
43
42
|
search: string;
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
class ItemsViewModel extends ViewModel<ItemState> {
|
|
47
46
|
// --- Private fields ---
|
|
48
47
|
private service = singleton(ItemService);
|
|
49
|
-
|
|
48
|
+
collection = singleton(ItemsCollection);
|
|
50
49
|
|
|
51
50
|
// --- Computed getters ---
|
|
51
|
+
get items(): Item[] {
|
|
52
|
+
return this.collection.items as Item[];
|
|
53
|
+
}
|
|
54
|
+
|
|
52
55
|
get filtered(): Item[] {
|
|
53
|
-
const {
|
|
54
|
-
if (!search) return items;
|
|
56
|
+
const { search } = this.state;
|
|
57
|
+
if (!search) return this.items;
|
|
55
58
|
const q = search.toLowerCase();
|
|
56
|
-
return items.filter(i => i.name.toLowerCase().includes(q));
|
|
59
|
+
return this.items.filter(i => i.name.toLowerCase().includes(q));
|
|
57
60
|
}
|
|
58
61
|
|
|
59
|
-
get total(): number { return this.
|
|
62
|
+
get total(): number { return this.items.length; }
|
|
60
63
|
|
|
61
64
|
// --- Lifecycle ---
|
|
62
65
|
protected onInit() {
|
|
63
|
-
|
|
64
|
-
this.set({ items: this.collection.items });
|
|
65
|
-
});
|
|
66
|
-
if (this.collection.length > 0) {
|
|
67
|
-
this.set({ items: this.collection.items });
|
|
68
|
-
} else {
|
|
69
|
-
this.load();
|
|
70
|
-
}
|
|
66
|
+
if (this.collection.length === 0) this.load();
|
|
71
67
|
}
|
|
72
68
|
|
|
73
69
|
// --- Actions ---
|
|
@@ -87,7 +83,7 @@ class ItemsViewModel extends ViewModel<ItemState> {
|
|
|
87
83
|
|
|
88
84
|
```tsx
|
|
89
85
|
function ItemsPage() {
|
|
90
|
-
const [state, vm] = useLocal(ItemsViewModel, {
|
|
86
|
+
const [state, vm] = useLocal(ItemsViewModel, { search: '' });
|
|
91
87
|
const { loading, error } = vm.async.load;
|
|
92
88
|
|
|
93
89
|
return (
|
|
@@ -133,6 +129,7 @@ class UserService extends Service {
|
|
|
133
129
|
```typescript
|
|
134
130
|
class UsersCollection extends Collection<UserState> {}
|
|
135
131
|
// Thin subclass. Query logic in ViewModel getters.
|
|
132
|
+
// Use reset() for full loads, upsert() for paginated/incremental loads.
|
|
136
133
|
```
|
|
137
134
|
|
|
138
135
|
## Model Pattern
|
|
@@ -181,7 +178,7 @@ async toggleStatus(id: string) {
|
|
|
181
178
|
try {
|
|
182
179
|
await this.service.update(id, { status: 'done' }, this.disposeSignal);
|
|
183
180
|
} catch (e) {
|
|
184
|
-
if (!isAbortError(e)) rollback();
|
|
181
|
+
if (!isAbortError(e)) rollback(); // guard needed — rollback affects shared Collection
|
|
185
182
|
throw e;
|
|
186
183
|
}
|
|
187
184
|
}
|
|
@@ -190,9 +187,11 @@ async toggleStatus(id: string) {
|
|
|
190
187
|
## Error Handling Layers
|
|
191
188
|
|
|
192
189
|
1. **Async tracking** (automatic): Write happy path, read `vm.async.method.error`
|
|
193
|
-
2. **Imperative events** (explicit): try/catch + `emit()` + **re-throw
|
|
190
|
+
2. **Imperative events** (explicit): try/catch + `emit()` + **re-throw**. No `isAbortError()` guard needed — `emit()` and `set()` are no-ops after dispose.
|
|
194
191
|
3. **Error classification** (services): `throw HttpError`, `classifyError()`
|
|
195
192
|
|
|
193
|
+
**`isAbortError()` rule:** Only needed in catch blocks that affect shared state (Collection rollbacks). Not needed for `set()`/`emit()` or methods without try/catch.
|
|
194
|
+
|
|
196
195
|
## Testing
|
|
197
196
|
|
|
198
197
|
```typescript
|
|
@@ -220,6 +219,7 @@ test('example', () => {
|
|
|
220
219
|
- Manual try/catch for standard loads → async tracking handles it
|
|
221
220
|
- Two-step setters with refilter calls → getters auto-recompute
|
|
222
221
|
- Manual optimistic snapshot/restore → use `collection.optimistic()`
|
|
222
|
+
- `reset()` for paginated/incremental loads → use `upsert()` to accumulate data
|
|
223
223
|
- `useState`/`useMemo`/`useCallback` in connected components → ViewModel handles it
|
|
224
224
|
|
|
225
225
|
## Decision Framework
|