mvc-kit 2.12.5 → 2.13.0

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 (186) hide show
  1. package/agent-config/bin/postinstall.mjs +4 -3
  2. package/agent-config/bin/setup.mjs +5 -1
  3. package/agent-config/claude-code/agents/mvc-kit-architect.md +11 -8
  4. package/agent-config/claude-code/skills/guide/SKILL.md +20 -7
  5. package/agent-config/claude-code/skills/guide/patterns.md +12 -0
  6. package/agent-config/claude-code/skills/guide/recipes.md +510 -0
  7. package/agent-config/claude-code/skills/guide/testing.md +297 -0
  8. package/agent-config/claude-code/skills/review/SKILL.md +3 -13
  9. package/agent-config/claude-code/skills/review/checklist.md +30 -5
  10. package/agent-config/claude-code/skills/scaffold/SKILL.md +4 -13
  11. package/agent-config/lib/install-claude.mjs +84 -25
  12. package/dist/Channel.cjs +276 -300
  13. package/dist/Channel.cjs.map +1 -1
  14. package/dist/Channel.js +275 -299
  15. package/dist/Channel.js.map +1 -1
  16. package/dist/Collection.cjs +424 -504
  17. package/dist/Collection.cjs.map +1 -1
  18. package/dist/Collection.js +423 -503
  19. package/dist/Collection.js.map +1 -1
  20. package/dist/Controller.cjs +70 -67
  21. package/dist/Controller.cjs.map +1 -1
  22. package/dist/Controller.js +69 -66
  23. package/dist/Controller.js.map +1 -1
  24. package/dist/EventBus.cjs +77 -88
  25. package/dist/EventBus.cjs.map +1 -1
  26. package/dist/EventBus.js +76 -87
  27. package/dist/EventBus.js.map +1 -1
  28. package/dist/Feed.cjs +81 -77
  29. package/dist/Feed.cjs.map +1 -1
  30. package/dist/Feed.js +80 -76
  31. package/dist/Feed.js.map +1 -1
  32. package/dist/Model.cjs +181 -207
  33. package/dist/Model.cjs.map +1 -1
  34. package/dist/Model.js +179 -205
  35. package/dist/Model.js.map +1 -1
  36. package/dist/Pagination.cjs +75 -73
  37. package/dist/Pagination.cjs.map +1 -1
  38. package/dist/Pagination.js +74 -72
  39. package/dist/Pagination.js.map +1 -1
  40. package/dist/Pending.cjs +255 -287
  41. package/dist/Pending.cjs.map +1 -1
  42. package/dist/Pending.js +253 -285
  43. package/dist/Pending.js.map +1 -1
  44. package/dist/PersistentCollection.cjs +242 -285
  45. package/dist/PersistentCollection.cjs.map +1 -1
  46. package/dist/PersistentCollection.js +241 -284
  47. package/dist/PersistentCollection.js.map +1 -1
  48. package/dist/Resource.cjs +166 -174
  49. package/dist/Resource.cjs.map +1 -1
  50. package/dist/Resource.js +164 -172
  51. package/dist/Resource.js.map +1 -1
  52. package/dist/Selection.cjs +84 -94
  53. package/dist/Selection.cjs.map +1 -1
  54. package/dist/Selection.js +83 -93
  55. package/dist/Selection.js.map +1 -1
  56. package/dist/Service.cjs +54 -55
  57. package/dist/Service.cjs.map +1 -1
  58. package/dist/Service.js +53 -54
  59. package/dist/Service.js.map +1 -1
  60. package/dist/Sorting.cjs +102 -101
  61. package/dist/Sorting.cjs.map +1 -1
  62. package/dist/Sorting.js +102 -101
  63. package/dist/Sorting.js.map +1 -1
  64. package/dist/Trackable.cjs +112 -80
  65. package/dist/Trackable.cjs.map +1 -1
  66. package/dist/Trackable.js +111 -79
  67. package/dist/Trackable.js.map +1 -1
  68. package/dist/ViewModel.cjs +528 -576
  69. package/dist/ViewModel.cjs.map +1 -1
  70. package/dist/ViewModel.js +525 -573
  71. package/dist/ViewModel.js.map +1 -1
  72. package/dist/bindPublicMethods.cjs +43 -24
  73. package/dist/bindPublicMethods.cjs.map +1 -1
  74. package/dist/bindPublicMethods.js +43 -24
  75. package/dist/bindPublicMethods.js.map +1 -1
  76. package/dist/errors.cjs +67 -68
  77. package/dist/errors.cjs.map +1 -1
  78. package/dist/errors.js +68 -71
  79. package/dist/errors.js.map +1 -1
  80. package/dist/mvc-kit.cjs +44 -46
  81. package/dist/mvc-kit.js +5 -32
  82. package/dist/produceDraft.cjs +105 -95
  83. package/dist/produceDraft.cjs.map +1 -1
  84. package/dist/produceDraft.js +106 -97
  85. package/dist/produceDraft.js.map +1 -1
  86. package/dist/react/components/CardList.cjs +30 -40
  87. package/dist/react/components/CardList.cjs.map +1 -1
  88. package/dist/react/components/CardList.js +31 -41
  89. package/dist/react/components/CardList.js.map +1 -1
  90. package/dist/react/components/DataTable.cjs +146 -169
  91. package/dist/react/components/DataTable.cjs.map +1 -1
  92. package/dist/react/components/DataTable.js +147 -170
  93. package/dist/react/components/DataTable.js.map +1 -1
  94. package/dist/react/components/InfiniteScroll.cjs +51 -42
  95. package/dist/react/components/InfiniteScroll.cjs.map +1 -1
  96. package/dist/react/components/InfiniteScroll.js +52 -43
  97. package/dist/react/components/InfiniteScroll.js.map +1 -1
  98. package/dist/react/components/types.cjs +10 -6
  99. package/dist/react/components/types.cjs.map +1 -1
  100. package/dist/react/components/types.js +11 -9
  101. package/dist/react/components/types.js.map +1 -1
  102. package/dist/react/guards.cjs +10 -6
  103. package/dist/react/guards.cjs.map +1 -1
  104. package/dist/react/guards.js +11 -9
  105. package/dist/react/guards.js.map +1 -1
  106. package/dist/react/provider.cjs +23 -20
  107. package/dist/react/provider.cjs.map +1 -1
  108. package/dist/react/provider.js +23 -21
  109. package/dist/react/provider.js.map +1 -1
  110. package/dist/react/use-event-bus.cjs +24 -20
  111. package/dist/react/use-event-bus.cjs.map +1 -1
  112. package/dist/react/use-event-bus.js +24 -21
  113. package/dist/react/use-event-bus.js.map +1 -1
  114. package/dist/react/use-instance.cjs +43 -36
  115. package/dist/react/use-instance.cjs.map +1 -1
  116. package/dist/react/use-instance.js +43 -36
  117. package/dist/react/use-instance.js.map +1 -1
  118. package/dist/react/use-local.cjs +48 -64
  119. package/dist/react/use-local.cjs.map +1 -1
  120. package/dist/react/use-local.js +47 -63
  121. package/dist/react/use-local.js.map +1 -1
  122. package/dist/react/use-model.cjs +84 -98
  123. package/dist/react/use-model.cjs.map +1 -1
  124. package/dist/react/use-model.js +84 -100
  125. package/dist/react/use-model.js.map +1 -1
  126. package/dist/react/use-singleton.cjs +19 -23
  127. package/dist/react/use-singleton.cjs.map +1 -1
  128. package/dist/react/use-singleton.js +16 -20
  129. package/dist/react/use-singleton.js.map +1 -1
  130. package/dist/react/use-subscribe-only.cjs +28 -22
  131. package/dist/react/use-subscribe-only.cjs.map +1 -1
  132. package/dist/react/use-subscribe-only.js +28 -22
  133. package/dist/react/use-subscribe-only.js.map +1 -1
  134. package/dist/react/use-teardown.cjs +20 -19
  135. package/dist/react/use-teardown.cjs.map +1 -1
  136. package/dist/react/use-teardown.js +20 -19
  137. package/dist/react/use-teardown.js.map +1 -1
  138. package/dist/react-native/NativeCollection.cjs +98 -78
  139. package/dist/react-native/NativeCollection.cjs.map +1 -1
  140. package/dist/react-native/NativeCollection.js +97 -77
  141. package/dist/react-native/NativeCollection.js.map +1 -1
  142. package/dist/react-native.cjs +2 -4
  143. package/dist/react-native.js +1 -4
  144. package/dist/react.cjs +24 -26
  145. package/dist/react.js +1 -17
  146. package/dist/singleton.cjs +28 -22
  147. package/dist/singleton.cjs.map +1 -1
  148. package/dist/singleton.js +29 -26
  149. package/dist/singleton.js.map +1 -1
  150. package/dist/walkPrototypeChain.cjs +20 -12
  151. package/dist/walkPrototypeChain.cjs.map +1 -1
  152. package/dist/walkPrototypeChain.js +21 -13
  153. package/dist/walkPrototypeChain.js.map +1 -1
  154. package/dist/web/IndexedDBCollection.cjs +53 -36
  155. package/dist/web/IndexedDBCollection.cjs.map +1 -1
  156. package/dist/web/IndexedDBCollection.js +52 -35
  157. package/dist/web/IndexedDBCollection.js.map +1 -1
  158. package/dist/web/WebStorageCollection.cjs +82 -84
  159. package/dist/web/WebStorageCollection.cjs.map +1 -1
  160. package/dist/web/WebStorageCollection.js +81 -83
  161. package/dist/web/WebStorageCollection.js.map +1 -1
  162. package/dist/web/idb.cjs +107 -99
  163. package/dist/web/idb.cjs.map +1 -1
  164. package/dist/web/idb.js +108 -105
  165. package/dist/web/idb.js.map +1 -1
  166. package/dist/web.cjs +4 -6
  167. package/dist/web.js +1 -5
  168. package/dist/wrapAsyncMethods.cjs +141 -168
  169. package/dist/wrapAsyncMethods.cjs.map +1 -1
  170. package/dist/wrapAsyncMethods.js +141 -168
  171. package/dist/wrapAsyncMethods.js.map +1 -1
  172. package/package.json +8 -8
  173. package/src/Pending.test.ts +1 -2
  174. package/src/Sorting.test.ts +1 -1
  175. package/src/produceDraft.test.ts +3 -3
  176. package/src/react/components/CardList.test.tsx +1 -1
  177. package/src/react/components/DataTable.test.tsx +1 -1
  178. package/src/react/components/InfiniteScroll.test.tsx +5 -5
  179. package/dist/mvc-kit.cjs.map +0 -1
  180. package/dist/mvc-kit.js.map +0 -1
  181. package/dist/react-native.cjs.map +0 -1
  182. package/dist/react-native.js.map +0 -1
  183. package/dist/react.cjs.map +0 -1
  184. package/dist/react.js.map +0 -1
  185. package/dist/web.cjs.map +0 -1
  186. package/dist/web.js.map +0 -1
@@ -21,11 +21,12 @@ if (resolve(projectRoot) === ownPackageRoot) {
21
21
  }
22
22
 
23
23
  // If the developer previously opted in (files exist), auto-update them
24
- const skillFile = join(projectRoot, '.claude', 'skills', 'mvc-kit.md');
25
- // Also detect legacy rules file from older versions
24
+ const skillDir = join(projectRoot, '.claude', 'skills', 'mvc-kit');
25
+ // Also detect legacy file paths from older versions
26
+ const legacySkillFile = join(projectRoot, '.claude', 'skills', 'mvc-kit.md');
26
27
  const legacyRulesFile = join(projectRoot, '.claude', 'rules', 'mvc-kit.md');
27
28
 
28
- if (existsSync(skillFile) || existsSync(legacyRulesFile)) {
29
+ if (existsSync(skillDir) || existsSync(legacySkillFile) || existsSync(legacyRulesFile)) {
29
30
  try {
30
31
  const { installClaude } = await import('../lib/install-claude.mjs');
31
32
  installClaude(projectRoot);
@@ -87,7 +87,11 @@ function setupClaude() {
87
87
  console.log(` ${f}`);
88
88
  }
89
89
  console.log('');
90
- console.log(' Skill: /project:mvc-kit (framework reference, on-demand)');
90
+ console.log(' Skills:');
91
+ console.log(' mvc-kit — framework reference (auto-loaded by Claude)');
92
+ console.log(' /mvc-kit-review — review code for pattern adherence');
93
+ console.log(' /mvc-kit-scaffold — scaffold classes with correct patterns');
94
+ console.log('');
91
95
  console.log(' Agent: mvc-kit-architect (architecture planning)\n');
92
96
  }
93
97
 
@@ -1,24 +1,27 @@
1
1
  ---
2
2
  name: mvc-kit-architect
3
3
  description: "Architecture planning agent for mvc-kit applications. Decides which classes to create, designs state shapes, and selects sharing patterns. Use when planning features, designing ViewModels, or deciding between class roles."
4
- model: sonnet
4
+ model: opus
5
+ tools: Read, Grep, Glob, Bash
6
+ memory: project
7
+ color: blue
8
+ skills:
9
+ - mvc-kit-guide
5
10
  ---
6
11
 
7
12
  You are an architecture planning agent for applications built with **mvc-kit**, a TypeScript-first reactive state management library for React. You help developers plan features by deciding which classes to create, designing state shapes, and selecting sharing patterns.
8
13
 
9
14
  ## Documentation
10
15
 
11
- For detailed, up-to-date documentation on any class or hook, search the `.md` files colocated with source in `node_modules/mvc-kit/src/`. Read these when you need specifics beyond the summary tables below.
16
+ The mvc-kit framework reference skill is preloaded into this agent's context. For deeper reference, read the supporting files in the mvc-kit skill directory (search for `.claude/skills/mvc-kit/` or `node_modules/mvc-kit/agent-config/claude-code/skills/guide/`):
12
17
 
13
- **Core classes:** `ViewModel.md`, `Model.md`, `Collection.md`, `PersistentCollection.md`, `Resource.md`, `Service.md`, `EventBus.md`, `Channel.md`, `Controller.md`, `Trackable.md`, `singleton.md`
14
- **Composable helpers:** `Sorting.md`, `Pagination.md`, `Selection.md`, `Feed.md`, `Pending.md`, `produceDraft.md`
15
- **React hooks:** `react/use-local.md`, `react/use-instance.md`, `react/use-singleton.md`, `react/use-model.md`, `react/use-event-bus.md`, `react/use-teardown.md`
16
- **Headless components:** `react/components/DataTable.md`, `react/components/CardList.md`, `react/components/InfiniteScroll.md`
17
-
18
- Additional reference files in `node_modules/mvc-kit/agent-config/claude-code/skills/guide/`:
19
18
  - `api-reference.md` — Full API reference for all classes and hooks
20
19
  - `patterns.md` — Prescribed patterns with code examples
21
20
  - `anti-patterns.md` — Anti-patterns to reject with fixes
21
+ - `recipes.md` — Composition recipes for real-world features
22
+ - `testing.md` — Testing patterns
23
+
24
+ For detailed per-class documentation, search the `.md` files colocated with source in `node_modules/mvc-kit/src/`.
22
25
 
23
26
  ## Core Classes
24
27
 
@@ -1,8 +1,7 @@
1
1
  ---
2
- name: guide
3
- description: "mvc-kit framework reference — class roles, architecture rules, React hooks, and decision framework. Auto-loaded when mvc-kit imports are detected."
4
- invocable_by:
5
- - model
2
+ name: mvc-kit-guide
3
+ description: "mvc-kit framework reference — class roles, architecture rules, React hooks, and decision framework. Use when working with mvc-kit imports or planning mvc-kit features."
4
+ user-invocable: false
6
5
  ---
7
6
 
8
7
  # mvc-kit Framework Reference
@@ -79,8 +78,22 @@ Import core from `mvc-kit`, hooks from `mvc-kit/react`.
79
78
  2. State survives route changes → **Pattern B** (singleton ViewModel via `useSingleton`)
80
79
  3. Different concerns, shared data → **Pattern C** (separate ViewModels, shared Collection)
81
80
 
81
+ ## Common Compositions
82
+
83
+ Most features combine multiple classes. These are the key composition patterns:
84
+
85
+ - **Data table**: ViewModel + Collection/Resource + Sorting + Pagination + Selection → getter pipeline (`items → filtered → sorted → paged`). Always reset pagination when filters change.
86
+ - **Infinite scroll / chat**: ViewModel + Feed + Service → cursor-based server pagination with `appendPage()`. Use `AbortSignal.any()` for per-call cancellation on rapid user interactions.
87
+ - **Form with validation**: ViewModel creates Model in `onInit()`, disposes in `onDispose()`. Guard `save()` on `model.valid`. Call `model.commit()` after successful save.
88
+ - **Two-tier events**: ViewModel `.emit()` for component reactions (navigate, show inline error). EventBus `.emit()` for app-wide side effects (toasts, analytics).
89
+ - **Singleton with DEFAULT_STATE**: `static DEFAULT_STATE` lets `singleton()` and `useSingleton()` work without repeating initial state at every call site.
90
+
91
+ For full composition recipes with code, see [recipes.md](recipes.md).
92
+
82
93
  ## Supporting Files
83
94
 
84
- For complete API details, see `api-reference.md` in this skill directory.
85
- For prescribed patterns with code examples, see `patterns.md`.
86
- For anti-pattern rejection list with fixes, see `anti-patterns.md`.
95
+ - [api-reference.md](api-reference.md) Full API reference for all classes and hooks
96
+ - [patterns.md](patterns.md) — Prescribed patterns with code examples
97
+ - [anti-patterns.md](anti-patterns.md) Anti-patterns to reject with fixes
98
+ - [recipes.md](recipes.md) — Composition recipes for real-world features
99
+ - [testing.md](testing.md) — Testing patterns (teardownAll, async assertions, memoization verification)
@@ -67,6 +67,16 @@ The setter changes state → React re-renders → getters recompute. No manual r
67
67
 
68
68
  All classes auto-bind methods, so they can be passed point-free as callbacks (`onFoo={vm.foo}`, `onClick={sorting.toggle}`, `onSubmit={model.commit}`).
69
69
 
70
+ ### Reset Pagination on Filter Changes
71
+
72
+ When a ViewModel uses Pagination, **every setter that changes filter/sort criteria must call `pagination.reset()`**. Otherwise the user can be on an invalid page (e.g., page 5 when results shrink to 2 pages).
73
+
74
+ ```typescript
75
+ setSearch(search: string) { this.set({ search }); this.pagination.reset(); }
76
+ setRoleFilter(roleFilter: State['roleFilter']) { this.set({ roleFilter }); this.pagination.reset(); }
77
+ setStatusFilter(statusFilter: State['statusFilter']) { this.set({ statusFilter }); this.pagination.reset(); }
78
+ ```
79
+
70
80
  ---
71
81
 
72
82
  ## Encapsulating Collections
@@ -271,6 +281,8 @@ class UsersListVM extends ViewModel<FilterState> {
271
281
  ```
272
282
 
273
283
  **Key points:**
284
+ - Helpers are `readonly` **public** properties — DataTable and other headless components need direct access to call `sorting.toggle()`, `pagination.goNext()`, `selection.toggle()`.
285
+ - Helpers extend `Trackable` and have `subscribe()` — they're **auto-tracked** when declared as ViewModel properties. Getters that read helper state (e.g., `sorting.sorts`) recompute automatically when the helper changes. No `subscribeTo()` needed.
274
286
  - Helpers are plain classes, not ViewModels — no `init()` needed.
275
287
  - `apply()` is a pure pipeline — chain them in getters: `pagination.apply(sorting.apply(filtered))`.
276
288
  - `Selection` is key-based — works with any ID type.
@@ -0,0 +1,510 @@
1
+ # mvc-kit Composition Recipes
2
+
3
+ Real-world features combine multiple classes. These recipes show the correct composition patterns.
4
+
5
+ ---
6
+
7
+ ## Recipe 1: Data Table with Filters, Sorting, Pagination, and Selection
8
+
9
+ The most common pattern. ViewModel owns helpers as `readonly` public properties. Getters form a pipeline: `items → filtered → sorted → paged`. DataTable receives helper instances directly.
10
+
11
+ ```typescript
12
+ interface UsersState {
13
+ search: string;
14
+ roleFilter: 'all' | 'admin' | 'manager' | 'viewer';
15
+ statusFilter: 'all' | 'active' | 'inactive';
16
+ }
17
+
18
+ class UsersViewModel extends ViewModel<UsersState> {
19
+ // Helpers are readonly public — DataTable needs direct access
20
+ readonly sorting = new Sorting<UserState>({ sorts: [{ key: 'firstName', direction: 'asc' }] });
21
+ readonly pagination = new Pagination({ pageSize: 25 });
22
+ readonly selection = new Selection<string>();
23
+
24
+ private users = singleton(UsersResource);
25
+
26
+ // --- Computed getters (pipeline) ---
27
+
28
+ get items(): UserState[] {
29
+ return this.users.items as UserState[];
30
+ }
31
+
32
+ get filtered(): UserState[] {
33
+ const { search, roleFilter, statusFilter } = this.state;
34
+ let result = this.items;
35
+ if (search) {
36
+ const q = search.toLowerCase();
37
+ result = result.filter(u =>
38
+ u.firstName.toLowerCase().includes(q) ||
39
+ u.lastName.toLowerCase().includes(q) ||
40
+ u.email.toLowerCase().includes(q),
41
+ );
42
+ }
43
+ if (roleFilter !== 'all') result = result.filter(u => u.role === roleFilter);
44
+ if (statusFilter !== 'all') result = result.filter(u => u.status === statusFilter);
45
+ return result;
46
+ }
47
+
48
+ get sorted(): UserState[] {
49
+ return this.sorting.apply(this.filtered);
50
+ }
51
+
52
+ get paged(): UserState[] {
53
+ return this.pagination.apply(this.sorted);
54
+ }
55
+
56
+ get total(): number { return this.items.length; }
57
+ get filteredCount(): number { return this.filtered.length; }
58
+ get hasResults(): boolean { return this.filtered.length > 0; }
59
+ get isEmpty(): boolean { return this.total > 0 && !this.hasResults; }
60
+
61
+ get selectedItems(): UserState[] {
62
+ return this.selection.selectedFrom(this.filtered, u => u.id);
63
+ }
64
+
65
+ // --- Lifecycle ---
66
+
67
+ protected onInit() {
68
+ if (this.users.length === 0) this.users.loadAll();
69
+ }
70
+
71
+ // --- Actions ---
72
+
73
+ async bulkDelete() {
74
+ const items = this.selectedItems;
75
+ if (items.length === 0) return;
76
+ for (const item of items) {
77
+ await this.users.deleteItem(item.id);
78
+ }
79
+ this.selection.clear();
80
+ }
81
+
82
+ // --- Setters (MUST reset pagination on filter changes) ---
83
+
84
+ setSearch(search: string) { this.set({ search }); this.pagination.reset(); }
85
+ setRoleFilter(roleFilter: UsersState['roleFilter']) { this.set({ roleFilter }); this.pagination.reset(); }
86
+ setStatusFilter(statusFilter: UsersState['statusFilter']) { this.set({ statusFilter }); this.pagination.reset(); }
87
+ }
88
+ ```
89
+
90
+ ```tsx
91
+ function UsersPage() {
92
+ const [state, vm] = useLocal(UsersViewModel, { search: '', roleFilter: 'all', statusFilter: 'all' });
93
+ const { loading, error } = vm.async.loadAll ?? {};
94
+
95
+ return (
96
+ <DataTable
97
+ items={vm.paged}
98
+ columns={columns}
99
+ sort={vm.sorting}
100
+ selection={vm.selection}
101
+ pagination={vm.pagination}
102
+ paginationTotal={vm.filteredCount}
103
+ loading={loading}
104
+ error={error?.message}
105
+ />
106
+ );
107
+ }
108
+ ```
109
+
110
+ **Key rules:**
111
+ - Helpers are `readonly` public properties — not private, not methods
112
+ - Pipeline: each getter composes the previous one (`sorted` calls `this.filtered`)
113
+ - **Always reset pagination when filters change** — otherwise the user can be on an invalid page
114
+ - Selection operates on `filtered`, not `paged` (select across pages)
115
+ - DataTable receives helper instances directly (duck-typed, not wrapped)
116
+ - `paginationTotal` is `filteredCount`, not `total` (pagination needs the filtered count)
117
+
118
+ ---
119
+
120
+ ## Recipe 2: Infinite Scroll with Cursor-Based Server Pagination
121
+
122
+ Feed tracks cursor + hasMore for server-side paginated data. InfiniteScroll triggers loading more.
123
+
124
+ ```typescript
125
+ interface ThreadState {
126
+ conversationId: string;
127
+ draft: string;
128
+ }
129
+
130
+ interface ThreadEvents {
131
+ messageSent: { conversationId: string };
132
+ }
133
+
134
+ class MessageThreadViewModel extends ViewModel<ThreadState, ThreadEvents> {
135
+ readonly feed = new Feed<MessageState>();
136
+
137
+ private service = singleton(MessageService);
138
+ private _loadController: AbortController | null = null;
139
+
140
+ // --- Computed getters ---
141
+
142
+ get sortedMessages(): MessageState[] {
143
+ return [...this.feed.items].sort(
144
+ (a, b) => new Date(a.sentAt).getTime() - new Date(b.sentAt).getTime(),
145
+ );
146
+ }
147
+
148
+ get canSend(): boolean {
149
+ return this.state.draft.trim().length > 0;
150
+ }
151
+
152
+ // --- Lifecycle ---
153
+
154
+ protected onInit() {
155
+ // Load conversation on init — no useEffect needed.
156
+ // useLocal deps recreate the VM when conversationId changes, triggering onInit again.
157
+ this.loadConversation(this.state.conversationId);
158
+ }
159
+
160
+ // --- Actions ---
161
+
162
+ async loadConversation(conversationId: string) {
163
+ // Per-call cancellation: abort previous in-flight load
164
+ this._loadController?.abort();
165
+ this._loadController = new AbortController();
166
+
167
+ this.feed.reset();
168
+
169
+ // Compose signals: abort if component unmounts OR user switches conversations
170
+ const page = await this.service.getMessages(
171
+ conversationId,
172
+ AbortSignal.any([this.disposeSignal, this._loadController.signal]),
173
+ );
174
+ this.feed.appendPage(page);
175
+ }
176
+
177
+ async loadOlderMessages() {
178
+ if (!this.feed.hasMore) return;
179
+
180
+ const page = await this.service.getMessages(
181
+ this.state.conversationId,
182
+ this.disposeSignal,
183
+ { cursor: this.feed.cursor },
184
+ );
185
+ this.feed.appendPage(page);
186
+ }
187
+
188
+ async sendMessage() {
189
+ const text = this.state.draft.trim();
190
+ if (!text) return;
191
+
192
+ const message = await this.service.sendMessage(
193
+ this.state.conversationId,
194
+ text,
195
+ this.disposeSignal,
196
+ );
197
+ this.feed.push(message);
198
+ this.set({ draft: '' });
199
+ this.emit('messageSent', { conversationId: this.state.conversationId });
200
+ }
201
+
202
+ // --- Setters ---
203
+
204
+ setDraft(draft: string) { this.set({ draft }); }
205
+ }
206
+ ```
207
+
208
+ ```tsx
209
+ function MessageThread({ conversationId }: { conversationId: string }) {
210
+ // deps: [conversationId] — recreates VM when conversation changes, onInit() loads new data
211
+ const [state, vm] = useLocal(MessageThreadViewModel, { conversationId, draft: '' }, [conversationId]);
212
+ const { loading } = vm.async.loadConversation ?? {};
213
+ const { loading: loadingMore } = vm.async.loadOlderMessages ?? {};
214
+
215
+ return (
216
+ <div>
217
+ <InfiniteScroll
218
+ hasMore={vm.feed.hasMore}
219
+ loading={loadingMore}
220
+ onLoadMore={() => vm.loadOlderMessages()}
221
+ direction="up"
222
+ >
223
+ {vm.sortedMessages.map(msg => <Message key={msg.id} message={msg} />)}
224
+ </InfiniteScroll>
225
+ <input value={state.draft} onChange={e => vm.setDraft(e.target.value)} />
226
+ <button disabled={!vm.canSend} onClick={() => vm.sendMessage()}>Send</button>
227
+ </div>
228
+ );
229
+ }
230
+ ```
231
+
232
+ **Key rules:**
233
+ - **No `useEffect` for data loading** — put `conversationId` in state, load in `onInit()`. `useLocal` deps recreate the VM when props change, triggering `onInit()` again.
234
+ - Service returns `FeedPage<T>`: `{ items: T[], hasMore: boolean, cursor?: string }`
235
+ - Call `feed.appendPage(page)` to accumulate items and update cursor/hasMore
236
+ - Pass `feed.cursor` when requesting the next page
237
+ - **Per-call cancellation**: `AbortSignal.any([this.disposeSignal, controller.signal])` aborts if EITHER the component unmounts or the user switches context
238
+ - `disposeSignal` alone is NOT enough for rapid user interactions (switching conversations, search-as-you-type)
239
+ - `direction="up"` for chat UIs (loads older items at top)
240
+
241
+ ---
242
+
243
+ ## Recipe 3: Form with Model Validation
244
+
245
+ ViewModel creates and owns a Model instance. Model handles validation, dirty tracking, commit/rollback. ViewModel handles async save.
246
+
247
+ ```typescript
248
+ interface ProfileState {
249
+ location: LocationState | null;
250
+ locationId: string;
251
+ }
252
+
253
+ interface ProfileEvents {
254
+ saved: { id: string };
255
+ }
256
+
257
+ class LocationProfileViewModel extends ViewModel<ProfileState, ProfileEvents> {
258
+ // Model is created dynamically after data loads — starts null
259
+ public model: LocationFormModel | null = null;
260
+
261
+ private service = singleton(LocationService);
262
+ private collection = singleton(LocationsCollection);
263
+ private bus = singleton(AppEventBus);
264
+
265
+ // --- Computed getters ---
266
+
267
+ get canSave(): boolean {
268
+ return this.model !== null && this.model.valid && this.model.dirty;
269
+ }
270
+
271
+ // --- Lifecycle ---
272
+
273
+ protected onInit() {
274
+ this.load();
275
+ }
276
+
277
+ protected onDispose() {
278
+ // Model has its own lifecycle — must dispose
279
+ this.model?.dispose();
280
+ }
281
+
282
+ // --- Actions ---
283
+
284
+ async load() {
285
+ const location = await this.service.getById(this.state.locationId, this.disposeSignal);
286
+
287
+ this.model = new LocationFormModel({
288
+ name: location.name,
289
+ type: location.type,
290
+ city: location.city,
291
+ capacity: location.capacity,
292
+ });
293
+ this.set({ location });
294
+ }
295
+
296
+ async save() {
297
+ if (!this.model || !this.model.valid) return;
298
+
299
+ try {
300
+ const updated = await this.service.update(
301
+ this.state.locationId,
302
+ this.model.state,
303
+ this.disposeSignal,
304
+ );
305
+ this.collection.update(this.state.locationId, updated);
306
+ this.model.commit(); // Reset dirty state after successful save
307
+ this.set({ location: updated });
308
+ this.emit('saved', { id: updated.id });
309
+ this.bus.emit('toast:show', { message: 'Location saved', severity: 'success' });
310
+ } catch (e) {
311
+ if (!isAbortError(e)) {
312
+ this.bus.emit('toast:show', { message: 'Failed to save', severity: 'error' });
313
+ }
314
+ throw e; // Re-throw so async tracking captures it
315
+ }
316
+ }
317
+ }
318
+ ```
319
+
320
+ ```tsx
321
+ function LocationProfile({ locationId }: { locationId: string }) {
322
+ const [state, vm] = useLocal(LocationProfileViewModel, { location: null, locationId });
323
+ const { loading: saving } = vm.async.save ?? {};
324
+
325
+ useEvent(vm, 'saved', () => navigate('/locations'));
326
+
327
+ if (!vm.model) return <Spinner />;
328
+
329
+ return (
330
+ <form onSubmit={e => { e.preventDefault(); vm.save(); }}>
331
+ <ModelFields model={vm.model} />
332
+ <button disabled={!vm.canSave || saving}>
333
+ {saving ? 'Saving...' : 'Save'}
334
+ </button>
335
+ </form>
336
+ );
337
+ }
338
+ ```
339
+
340
+ **Key rules:**
341
+ - Model is created in `onInit()` (or after async load), disposed in `onDispose()` — **always pair create/dispose**
342
+ - Guard `save()` with `model.valid` — never submit invalid data
343
+ - Call `model.commit()` after successful save to reset dirty tracking
344
+ - Use `model.rollback()` for cancel/discard
345
+ - For large forms: use `useModelRef(factory)` + `useField(model, 'fieldName')` for surgical per-field re-renders
346
+ - ViewModel events (`emit('saved')`) for navigation; EventBus for app-wide toasts
347
+
348
+ ---
349
+
350
+ ## Recipe 4: Two-Tier Event System
351
+
352
+ ViewModel events are for "this instance did something" (component reactions). EventBus events are for "something happened app-wide" (toasts, analytics, navigation).
353
+
354
+ ```typescript
355
+ // App-wide EventBus — singleton
356
+ interface AppEvents {
357
+ 'toast:show': { message: string; severity: 'success' | 'error' | 'info' };
358
+ 'analytics:track': { event: string; properties?: Record<string, unknown> };
359
+ }
360
+
361
+ class AppEventBus extends EventBus<AppEvents> {}
362
+
363
+ // ViewModel with both tiers
364
+ interface AuthState { user: UserState | null; isAuthenticated: boolean; }
365
+ interface AuthEvents { loginFailed: { message: string }; }
366
+
367
+ class AuthViewModel extends ViewModel<AuthState, AuthEvents> {
368
+ static DEFAULT_STATE: AuthState = { user: null, isAuthenticated: false };
369
+
370
+ private authService = singleton(AuthService);
371
+ private bus = singleton(AppEventBus);
372
+
373
+ async login(email: string, password: string) {
374
+ try {
375
+ const user = await this.authService.login(email, password, this.disposeSignal);
376
+ this.set({ user, isAuthenticated: true });
377
+ // App-wide: toast notification
378
+ this.bus.emit('toast:show', { message: `Welcome, ${user.firstName}!`, severity: 'success' });
379
+ // App-wide: analytics
380
+ this.bus.emit('analytics:track', { event: 'login', properties: { role: user.role } });
381
+ } catch (e) {
382
+ if (!isAbortError(e)) {
383
+ // Instance-scoped: only the login form cares about this
384
+ this.emit('loginFailed', { message: classifyError(e).message });
385
+ }
386
+ throw e;
387
+ }
388
+ }
389
+ }
390
+ ```
391
+
392
+ ```tsx
393
+ // Login form listens to ViewModel events
394
+ function LoginPage() {
395
+ const [state, vm] = useLocal(AuthViewModel);
396
+ useEvent(vm, 'loginFailed', ({ message }) => setErrorBanner(message));
397
+ // ...
398
+ }
399
+
400
+ // Toast component listens to EventBus events (app root)
401
+ function ToastProvider() {
402
+ const bus = useInstance(singleton(AppEventBus));
403
+ useEvent(bus, 'toast:show', ({ message, severity }) => showToast(message, severity));
404
+ // ...
405
+ }
406
+ ```
407
+
408
+ **When to use which:**
409
+ | Use | For | Example |
410
+ |-----|-----|---------|
411
+ | ViewModel events (`this.emit()`) | Component-scoped reactions | `saved` → navigate, `loginFailed` → show error |
412
+ | EventBus (`bus.emit()`) | Cross-cutting, app-wide effects | Toasts, analytics, logging |
413
+
414
+ **Key rules:**
415
+ - ViewModel events use the second generic: `ViewModel<State, Events>`
416
+ - Components listen via `useEvent(vm, 'eventName', handler)`
417
+ - EventBus is a singleton — one per event domain (e.g., `AppEventBus`)
418
+ - Use `listenTo(bus, event, handler)` inside ViewModels for auto-cleanup
419
+
420
+ ---
421
+
422
+ ## Recipe 5: Resource with Singleton DEFAULT_STATE
423
+
424
+ Singleton ViewModels that share state across routes need `static DEFAULT_STATE` so `singleton()` and `useSingleton()` work without repeating initial state at every call site.
425
+
426
+ ```typescript
427
+ // Resource — thin subclass, async methods for CRUD
428
+ class UsersResource extends Resource<UserState> {
429
+ private api = singleton(UserService);
430
+
431
+ async loadAll() {
432
+ const data = await this.api.getAll(this.disposeSignal);
433
+ this.reset(data);
434
+ }
435
+ }
436
+
437
+ // Singleton ViewModel with DEFAULT_STATE
438
+ class AuthViewModel extends ViewModel<AuthState> {
439
+ static DEFAULT_STATE: AuthState = { user: null, isAuthenticated: false };
440
+ // ...
441
+ }
442
+
443
+ // Usage — no args needed at call sites
444
+ const auth = singleton(AuthViewModel); // bare singleton (no auto-init)
445
+ const [state, auth] = useSingleton(AuthViewModel); // React (auto-init + subscribe)
446
+ ```
447
+
448
+ **Key rules:**
449
+ - `static DEFAULT_STATE` is checked at runtime by `singleton()` when no args passed
450
+ - `singleton()` does NOT call `init()` — use `useSingleton()` in React, or call `init()` manually
451
+ - `useSingleton()` auto-calls `init()` and subscribes to state changes
452
+ - Singletons persist across route changes — use `useTeardown(...Classes)` to dispose when a route unmounts
453
+ - Smart-init in `onInit()`: `if (this.collection.length === 0) this.load()` prevents re-fetching on re-mount
454
+
455
+ ---
456
+
457
+ ## Recipe 6: Error Handling with isAbortError
458
+
459
+ Async tracking handles errors automatically. Manual try/catch is only needed when you have **side effects** in the catch block (toasts, event emissions, collection rollbacks).
460
+
461
+ ```typescript
462
+ class OrderViewModel extends ViewModel<OrderState> {
463
+ private resource = singleton(OrdersResource);
464
+ private bus = singleton(AppEventBus);
465
+
466
+ // Simple load — NO try/catch needed (async tracking handles it)
467
+ async load() {
468
+ const orders = await this.resource.loadAll();
469
+ }
470
+
471
+ // Save with side effects — try/catch + isAbortError guard
472
+ async save(order: OrderDraft) {
473
+ try {
474
+ const saved = await this.resource.save(order);
475
+ this.bus.emit('toast:show', { message: 'Order saved', severity: 'success' });
476
+ } catch (e) {
477
+ // Guard shared-state side effects against AbortError
478
+ if (!isAbortError(e)) {
479
+ this.bus.emit('toast:show', { message: 'Save failed', severity: 'error' });
480
+ }
481
+ throw e; // ALWAYS re-throw — async tracking needs it
482
+ }
483
+ }
484
+
485
+ // Optimistic update — isAbortError guards the rollback
486
+ async toggleStatus(id: string) {
487
+ const order = this.resource.get(id)!;
488
+ const newStatus = order.status === 'active' ? 'inactive' : 'active';
489
+
490
+ const restore = this.resource.optimistic(id, { ...order, status: newStatus });
491
+ try {
492
+ const updated = await this.service.update(id, { status: newStatus }, this.disposeSignal);
493
+ this.resource.update(id, updated);
494
+ } catch (e) {
495
+ if (!isAbortError(e)) restore(); // Only rollback if NOT an abort
496
+ throw e;
497
+ }
498
+ }
499
+ }
500
+ ```
501
+
502
+ **When you need `isAbortError()`:**
503
+ - Before emitting events in catch blocks
504
+ - Before rolling back optimistic updates
505
+ - Before any shared-state side effect in catch
506
+
507
+ **When you DON'T need it:**
508
+ - `set()` — no-op after dispose
509
+ - `emit()` — guards on disposed state internally
510
+ - Simple loads with no catch block — async tracking handles everything