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.
Files changed (45) hide show
  1. package/README.md +14 -9
  2. package/agent-config/claude-code/agents/mvc-kit-architect.md +2 -3
  3. package/agent-config/claude-code/skills/guide/SKILL.md +2 -2
  4. package/agent-config/claude-code/skills/guide/anti-patterns.md +44 -7
  5. package/agent-config/claude-code/skills/guide/api-reference.md +5 -4
  6. package/agent-config/claude-code/skills/guide/patterns.md +22 -18
  7. package/agent-config/claude-code/skills/review/checklist.md +2 -2
  8. package/agent-config/claude-code/skills/scaffold/SKILL.md +1 -1
  9. package/agent-config/claude-code/skills/scaffold/templates/collection.md +7 -14
  10. package/agent-config/claude-code/skills/scaffold/templates/viewmodel.md +13 -42
  11. package/agent-config/copilot/copilot-instructions.md +18 -18
  12. package/agent-config/cursor/cursorrules +18 -18
  13. package/dist/Channel.d.ts +29 -0
  14. package/dist/Channel.d.ts.map +1 -1
  15. package/dist/Collection.d.ts +16 -1
  16. package/dist/Collection.d.ts.map +1 -1
  17. package/dist/Controller.d.ts +9 -0
  18. package/dist/Controller.d.ts.map +1 -1
  19. package/dist/EventBus.d.ts +5 -0
  20. package/dist/EventBus.d.ts.map +1 -1
  21. package/dist/Model.d.ts +16 -0
  22. package/dist/Model.d.ts.map +1 -1
  23. package/dist/Service.d.ts +8 -0
  24. package/dist/Service.d.ts.map +1 -1
  25. package/dist/ViewModel.d.ts +35 -1
  26. package/dist/ViewModel.d.ts.map +1 -1
  27. package/dist/mvc-kit.cjs +1 -1
  28. package/dist/mvc-kit.cjs.map +1 -1
  29. package/dist/mvc-kit.js +226 -111
  30. package/dist/mvc-kit.js.map +1 -1
  31. package/dist/react/provider.d.ts +1 -0
  32. package/dist/react/provider.d.ts.map +1 -1
  33. package/dist/react/use-model.d.ts +2 -0
  34. package/dist/react/use-model.d.ts.map +1 -1
  35. package/dist/react.cjs.map +1 -1
  36. package/dist/react.js +1 -1
  37. package/dist/react.js.map +1 -1
  38. package/dist/{singleton-C8_FRbA7.js → singleton-CaEXSbYg.js} +5 -1
  39. package/dist/singleton-CaEXSbYg.js.map +1 -0
  40. package/dist/singleton-L-u2W_lX.cjs.map +1 -1
  41. package/dist/singleton.d.ts +10 -0
  42. package/dist/singleton.d.ts.map +1 -1
  43. package/mvc-kit-logo.jpg +0 -0
  44. package/package.json +2 -1
  45. 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 ViewModelsreplace verbose AbortError checks
368
- if (isAbortError(e)) return;
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 UsersViewModel extends ViewModel<State> {
408
+ class ChatViewModel extends ViewModel<State> {
404
409
  protected onInit() {
405
- // Replaces: this.addCleanup(this.collection.subscribe(() => this.derive()))
406
- this.subscribeTo(this.collection, () => this.derive());
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 DOMException |
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 and subscription setup.
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.** ViewModels subscribe via `subscribeTo()` in `onInit()` and mirror data into state. Components never import Collections.
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
- if (!isAbortError(e)) {
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. Missing disposeSignal on Async Calls
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(item: T): void`
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(id: string): void`
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
- ViewModels subscribe internally. Components never see Collections.
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
- private collection = singleton(LocationsCollection);
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
- this.subscribeTo(this.collection, () => {
81
- this.set({ items: this.collection.items });
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
- if (!isAbortError(e)) {
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. Only guard with `isAbortError()`.
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. **Use `subscribeTo`** — Collection subscriptions must use `subscribeTo()` (auto-cleanup), not manual `subscribe()` + `addCleanup()`.
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 Collection subscriptions with smart init pattern.
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
- interface State {
26
- items: {{Name}}Item[];
27
- }
25
+ class {{Name}}ViewModel extends ViewModel<{ search: string }> {
26
+ collection = singleton({{Name}}Collection);
28
27
 
29
- class {{Name}}ViewModel extends ViewModel<State> {
30
- private collection = singleton({{Name}}Collection);
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
- this.subscribeTo(this.collection, () => {
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
- // private collection = singleton({{Name}}Collection);
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 { items, search } = this.state;
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.state.items.length;
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
- // Subscribe to collection and mirror data into state
46
- // this.subscribeTo(this.collection, () => {
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<{ items: any[]; search: string }> = {}) {
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.** ViewModels subscribe via `subscribeTo()` in `onInit()`. Components never import Collections.
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
- private collection = singleton(ItemsCollection);
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 { items, search } = this.state;
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.state.items.length; }
62
+ get total(): number { return this.items.length; }
60
63
 
61
64
  // --- Lifecycle ---
62
65
  protected onInit() {
63
- this.subscribeTo(this.collection, () => {
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, { items: [], search: '' });
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