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.
- package/agent-config/bin/postinstall.mjs +4 -3
- package/agent-config/bin/setup.mjs +5 -1
- package/agent-config/claude-code/agents/mvc-kit-architect.md +11 -8
- package/agent-config/claude-code/skills/guide/SKILL.md +20 -7
- package/agent-config/claude-code/skills/guide/patterns.md +12 -0
- package/agent-config/claude-code/skills/guide/recipes.md +510 -0
- package/agent-config/claude-code/skills/guide/testing.md +297 -0
- package/agent-config/claude-code/skills/review/SKILL.md +3 -13
- package/agent-config/claude-code/skills/review/checklist.md +30 -5
- package/agent-config/claude-code/skills/scaffold/SKILL.md +4 -13
- package/agent-config/lib/install-claude.mjs +84 -25
- package/dist/Channel.cjs +276 -300
- package/dist/Channel.cjs.map +1 -1
- package/dist/Channel.js +275 -299
- package/dist/Channel.js.map +1 -1
- package/dist/Collection.cjs +424 -504
- package/dist/Collection.cjs.map +1 -1
- package/dist/Collection.js +423 -503
- package/dist/Collection.js.map +1 -1
- package/dist/Controller.cjs +70 -67
- package/dist/Controller.cjs.map +1 -1
- package/dist/Controller.js +69 -66
- package/dist/Controller.js.map +1 -1
- package/dist/EventBus.cjs +77 -88
- package/dist/EventBus.cjs.map +1 -1
- package/dist/EventBus.js +76 -87
- package/dist/EventBus.js.map +1 -1
- package/dist/Feed.cjs +81 -77
- package/dist/Feed.cjs.map +1 -1
- package/dist/Feed.js +80 -76
- package/dist/Feed.js.map +1 -1
- package/dist/Model.cjs +181 -207
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.js +179 -205
- package/dist/Model.js.map +1 -1
- package/dist/Pagination.cjs +75 -73
- package/dist/Pagination.cjs.map +1 -1
- package/dist/Pagination.js +74 -72
- package/dist/Pagination.js.map +1 -1
- package/dist/Pending.cjs +255 -287
- package/dist/Pending.cjs.map +1 -1
- package/dist/Pending.js +253 -285
- package/dist/Pending.js.map +1 -1
- package/dist/PersistentCollection.cjs +242 -285
- package/dist/PersistentCollection.cjs.map +1 -1
- package/dist/PersistentCollection.js +241 -284
- package/dist/PersistentCollection.js.map +1 -1
- package/dist/Resource.cjs +166 -174
- package/dist/Resource.cjs.map +1 -1
- package/dist/Resource.js +164 -172
- package/dist/Resource.js.map +1 -1
- package/dist/Selection.cjs +84 -94
- package/dist/Selection.cjs.map +1 -1
- package/dist/Selection.js +83 -93
- package/dist/Selection.js.map +1 -1
- package/dist/Service.cjs +54 -55
- package/dist/Service.cjs.map +1 -1
- package/dist/Service.js +53 -54
- package/dist/Service.js.map +1 -1
- package/dist/Sorting.cjs +102 -101
- package/dist/Sorting.cjs.map +1 -1
- package/dist/Sorting.js +102 -101
- package/dist/Sorting.js.map +1 -1
- package/dist/Trackable.cjs +112 -80
- package/dist/Trackable.cjs.map +1 -1
- package/dist/Trackable.js +111 -79
- package/dist/Trackable.js.map +1 -1
- package/dist/ViewModel.cjs +528 -576
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.js +525 -573
- package/dist/ViewModel.js.map +1 -1
- package/dist/bindPublicMethods.cjs +43 -24
- package/dist/bindPublicMethods.cjs.map +1 -1
- package/dist/bindPublicMethods.js +43 -24
- package/dist/bindPublicMethods.js.map +1 -1
- package/dist/errors.cjs +67 -68
- package/dist/errors.cjs.map +1 -1
- package/dist/errors.js +68 -71
- package/dist/errors.js.map +1 -1
- package/dist/mvc-kit.cjs +44 -46
- package/dist/mvc-kit.js +5 -32
- package/dist/produceDraft.cjs +105 -95
- package/dist/produceDraft.cjs.map +1 -1
- package/dist/produceDraft.js +106 -97
- package/dist/produceDraft.js.map +1 -1
- package/dist/react/components/CardList.cjs +30 -40
- package/dist/react/components/CardList.cjs.map +1 -1
- package/dist/react/components/CardList.js +31 -41
- package/dist/react/components/CardList.js.map +1 -1
- package/dist/react/components/DataTable.cjs +146 -169
- package/dist/react/components/DataTable.cjs.map +1 -1
- package/dist/react/components/DataTable.js +147 -170
- package/dist/react/components/DataTable.js.map +1 -1
- package/dist/react/components/InfiniteScroll.cjs +51 -42
- package/dist/react/components/InfiniteScroll.cjs.map +1 -1
- package/dist/react/components/InfiniteScroll.js +52 -43
- package/dist/react/components/InfiniteScroll.js.map +1 -1
- package/dist/react/components/types.cjs +10 -6
- package/dist/react/components/types.cjs.map +1 -1
- package/dist/react/components/types.js +11 -9
- package/dist/react/components/types.js.map +1 -1
- package/dist/react/guards.cjs +10 -6
- package/dist/react/guards.cjs.map +1 -1
- package/dist/react/guards.js +11 -9
- package/dist/react/guards.js.map +1 -1
- package/dist/react/provider.cjs +23 -20
- package/dist/react/provider.cjs.map +1 -1
- package/dist/react/provider.js +23 -21
- package/dist/react/provider.js.map +1 -1
- package/dist/react/use-event-bus.cjs +24 -20
- package/dist/react/use-event-bus.cjs.map +1 -1
- package/dist/react/use-event-bus.js +24 -21
- package/dist/react/use-event-bus.js.map +1 -1
- package/dist/react/use-instance.cjs +43 -36
- package/dist/react/use-instance.cjs.map +1 -1
- package/dist/react/use-instance.js +43 -36
- package/dist/react/use-instance.js.map +1 -1
- package/dist/react/use-local.cjs +48 -64
- package/dist/react/use-local.cjs.map +1 -1
- package/dist/react/use-local.js +47 -63
- package/dist/react/use-local.js.map +1 -1
- package/dist/react/use-model.cjs +84 -98
- package/dist/react/use-model.cjs.map +1 -1
- package/dist/react/use-model.js +84 -100
- package/dist/react/use-model.js.map +1 -1
- package/dist/react/use-singleton.cjs +19 -23
- package/dist/react/use-singleton.cjs.map +1 -1
- package/dist/react/use-singleton.js +16 -20
- package/dist/react/use-singleton.js.map +1 -1
- package/dist/react/use-subscribe-only.cjs +28 -22
- package/dist/react/use-subscribe-only.cjs.map +1 -1
- package/dist/react/use-subscribe-only.js +28 -22
- package/dist/react/use-subscribe-only.js.map +1 -1
- package/dist/react/use-teardown.cjs +20 -19
- package/dist/react/use-teardown.cjs.map +1 -1
- package/dist/react/use-teardown.js +20 -19
- package/dist/react/use-teardown.js.map +1 -1
- package/dist/react-native/NativeCollection.cjs +98 -78
- package/dist/react-native/NativeCollection.cjs.map +1 -1
- package/dist/react-native/NativeCollection.js +97 -77
- package/dist/react-native/NativeCollection.js.map +1 -1
- package/dist/react-native.cjs +2 -4
- package/dist/react-native.js +1 -4
- package/dist/react.cjs +24 -26
- package/dist/react.js +1 -17
- package/dist/singleton.cjs +28 -22
- package/dist/singleton.cjs.map +1 -1
- package/dist/singleton.js +29 -26
- package/dist/singleton.js.map +1 -1
- package/dist/walkPrototypeChain.cjs +20 -12
- package/dist/walkPrototypeChain.cjs.map +1 -1
- package/dist/walkPrototypeChain.js +21 -13
- package/dist/walkPrototypeChain.js.map +1 -1
- package/dist/web/IndexedDBCollection.cjs +53 -36
- package/dist/web/IndexedDBCollection.cjs.map +1 -1
- package/dist/web/IndexedDBCollection.js +52 -35
- package/dist/web/IndexedDBCollection.js.map +1 -1
- package/dist/web/WebStorageCollection.cjs +82 -84
- package/dist/web/WebStorageCollection.cjs.map +1 -1
- package/dist/web/WebStorageCollection.js +81 -83
- package/dist/web/WebStorageCollection.js.map +1 -1
- package/dist/web/idb.cjs +107 -99
- package/dist/web/idb.cjs.map +1 -1
- package/dist/web/idb.js +108 -105
- package/dist/web/idb.js.map +1 -1
- package/dist/web.cjs +4 -6
- package/dist/web.js +1 -5
- package/dist/wrapAsyncMethods.cjs +141 -168
- package/dist/wrapAsyncMethods.cjs.map +1 -1
- package/dist/wrapAsyncMethods.js +141 -168
- package/dist/wrapAsyncMethods.js.map +1 -1
- package/package.json +8 -8
- package/src/Pending.test.ts +1 -2
- package/src/Sorting.test.ts +1 -1
- package/src/produceDraft.test.ts +3 -3
- package/src/react/components/CardList.test.tsx +1 -1
- package/src/react/components/DataTable.test.tsx +1 -1
- package/src/react/components/InfiniteScroll.test.tsx +5 -5
- package/dist/mvc-kit.cjs.map +0 -1
- package/dist/mvc-kit.js.map +0 -1
- package/dist/react-native.cjs.map +0 -1
- package/dist/react-native.js.map +0 -1
- package/dist/react.cjs.map +0 -1
- package/dist/react.js.map +0 -1
- package/dist/web.cjs.map +0 -1
- 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
|
|
25
|
-
// Also detect legacy
|
|
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(
|
|
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('
|
|
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:
|
|
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
|
-
|
|
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.
|
|
4
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|