mvc-kit 2.0.0 → 2.1.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/README.md +19 -0
- package/agent-config/bin/setup.mjs +217 -0
- package/agent-config/claude-code/.claude-plugin/plugin.json +5 -0
- package/agent-config/claude-code/agents/mvc-kit-architect.md +97 -0
- package/agent-config/claude-code/skills/guide/SKILL.md +85 -0
- package/agent-config/claude-code/skills/guide/anti-patterns.md +321 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +310 -0
- package/agent-config/claude-code/skills/guide/patterns.md +336 -0
- package/agent-config/claude-code/skills/review/SKILL.md +53 -0
- package/agent-config/claude-code/skills/review/checklist.md +89 -0
- package/agent-config/claude-code/skills/scaffold/SKILL.md +52 -0
- package/agent-config/claude-code/skills/scaffold/templates/channel.md +88 -0
- package/agent-config/claude-code/skills/scaffold/templates/collection.md +49 -0
- package/agent-config/claude-code/skills/scaffold/templates/controller.md +56 -0
- package/agent-config/claude-code/skills/scaffold/templates/eventbus.md +54 -0
- package/agent-config/claude-code/skills/scaffold/templates/model.md +102 -0
- package/agent-config/claude-code/skills/scaffold/templates/page-component.md +58 -0
- package/agent-config/claude-code/skills/scaffold/templates/service.md +101 -0
- package/agent-config/claude-code/skills/scaffold/templates/viewmodel.md +128 -0
- package/agent-config/copilot/copilot-instructions.md +242 -0
- package/agent-config/cursor/cursorrules +242 -0
- package/package.json +6 -2
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# mvc-kit Framework Instructions
|
|
2
|
+
|
|
3
|
+
This project uses **mvc-kit**, a zero-dependency TypeScript-first reactive state management library for React. Follow these rules strictly when writing or reviewing code.
|
|
4
|
+
|
|
5
|
+
## Core Classes
|
|
6
|
+
|
|
7
|
+
| Class | Role | Scope |
|
|
8
|
+
|-------|------|-------|
|
|
9
|
+
| `ViewModel<S, E?>` | Reactive state + computed getters + async tracking + typed events | Component-scoped (`useLocal`) |
|
|
10
|
+
| `Model<S>` | Entity with validation + dirty tracking + commit/rollback | Component-scoped (`useModel`) |
|
|
11
|
+
| `Collection<T>` | Reactive typed array, shared data cache, optimistic updates | Singleton |
|
|
12
|
+
| `Service` | Stateless infrastructure adapter (HTTP, storage) | Singleton |
|
|
13
|
+
| `EventBus<E>` | Typed pub/sub for cross-cutting events | Singleton |
|
|
14
|
+
| `Channel<M>` | Persistent connection (WebSocket/SSE) with auto-reconnect | Singleton |
|
|
15
|
+
| `Controller` | Stateless multi-ViewModel orchestrator (rare) | Component-scoped |
|
|
16
|
+
|
|
17
|
+
## Imports
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { ViewModel, Model, Collection, Controller, Service, EventBus, Channel } from 'mvc-kit';
|
|
21
|
+
import { singleton, teardownAll, HttpError, isAbortError, classifyError } from 'mvc-kit';
|
|
22
|
+
import { useLocal, useSingleton, useInstance, useModel, useField, useEvent, useEmit, useResolve, useTeardown, Provider } from 'mvc-kit/react';
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Architecture Rules
|
|
26
|
+
|
|
27
|
+
1. **State = source of truth only.** No derived values, no loading/error flags in state interfaces.
|
|
28
|
+
2. **Derived values are `get` accessors** on the ViewModel. Auto-memoized after `init()`.
|
|
29
|
+
3. **Async status is automatic.** After `init()`, `vm.async.methodName` returns `{ loading, error, errorCode }`. Never store loading/error in state.
|
|
30
|
+
4. **One ViewModel per component** via `useLocal`. No `useEffect` for data loading — use `onInit()`.
|
|
31
|
+
5. **No `useState`/`useMemo`/`useCallback`** — the ViewModel is the hook.
|
|
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.
|
|
34
|
+
8. **Services are stateless.** Accept `AbortSignal`, throw `HttpError`, no knowledge of ViewModels.
|
|
35
|
+
9. **Pass `this.disposeSignal`** to every async call.
|
|
36
|
+
10. **Lifecycle**: `construct → init() → use → dispose()`. Hooks auto-call `init()`.
|
|
37
|
+
|
|
38
|
+
## ViewModel Pattern
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
interface ItemState {
|
|
42
|
+
items: Item[];
|
|
43
|
+
search: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class ItemsViewModel extends ViewModel<ItemState> {
|
|
47
|
+
// --- Private fields ---
|
|
48
|
+
private service = singleton(ItemService);
|
|
49
|
+
private collection = singleton(ItemsCollection);
|
|
50
|
+
|
|
51
|
+
// --- Computed getters ---
|
|
52
|
+
get filtered(): Item[] {
|
|
53
|
+
const { items, search } = this.state;
|
|
54
|
+
if (!search) return items;
|
|
55
|
+
const q = search.toLowerCase();
|
|
56
|
+
return items.filter(i => i.name.toLowerCase().includes(q));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get total(): number { return this.state.items.length; }
|
|
60
|
+
|
|
61
|
+
// --- Lifecycle ---
|
|
62
|
+
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
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- Actions ---
|
|
74
|
+
async load() {
|
|
75
|
+
const data = await this.service.getAll(this.disposeSignal);
|
|
76
|
+
this.collection.reset(data);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- Setters ---
|
|
80
|
+
setSearch(search: string) { this.set({ search }); }
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Section order:** Private fields → Computed getters → Lifecycle → Actions → Setters.
|
|
85
|
+
|
|
86
|
+
## Component Pattern
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
function ItemsPage() {
|
|
90
|
+
const [state, vm] = useLocal(ItemsViewModel, { items: [], search: '' });
|
|
91
|
+
const { loading, error } = vm.async.load;
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div>
|
|
95
|
+
<input value={state.search} onChange={e => vm.setSearch(e.target.value)} />
|
|
96
|
+
{loading && <Spinner />}
|
|
97
|
+
{error && <ErrorBanner message={error} />}
|
|
98
|
+
<ItemsTable items={vm.filtered} />
|
|
99
|
+
<p>Showing {vm.filtered.length} of {vm.total}</p>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## React Hooks
|
|
106
|
+
|
|
107
|
+
| Hook | Usage |
|
|
108
|
+
|------|-------|
|
|
109
|
+
| `useLocal(Class, ...args)` | Component-scoped, auto-init/dispose. Returns `[state, instance]`. |
|
|
110
|
+
| `useSingleton(Class, ...args)` | Singleton, shared state. Returns `[state, instance]`. |
|
|
111
|
+
| `useInstance(subscribable)` | Subscribe to existing instance. Returns `state`. |
|
|
112
|
+
| `useModel(factory)` | Model with `{ state, errors, valid, dirty, model }`. |
|
|
113
|
+
| `useField(model, key)` | Single field: `{ value, error, set }`. |
|
|
114
|
+
| `useEvent(source, event, handler)` | EventBus/ViewModel event subscription. |
|
|
115
|
+
| `useEmit(bus)` | Stable emit function. |
|
|
116
|
+
| `useResolve(Class)` | Resolve from Provider or singleton. |
|
|
117
|
+
| `useTeardown(...Classes)` | Teardown singletons on unmount. |
|
|
118
|
+
|
|
119
|
+
## Service Pattern
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
class UserService extends Service {
|
|
123
|
+
async getAll(signal?: AbortSignal): Promise<User[]> {
|
|
124
|
+
const res = await fetch('/api/users', { signal });
|
|
125
|
+
if (!res.ok) throw new HttpError(res.status, res.statusText);
|
|
126
|
+
return res.json();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Collection Pattern
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
class UsersCollection extends Collection<UserState> {}
|
|
135
|
+
// Thin subclass. Query logic in ViewModel getters.
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Model Pattern
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
class UserModel extends Model<UserFormState> {
|
|
142
|
+
setName(name: string) { this.set({ name }); }
|
|
143
|
+
|
|
144
|
+
protected validate(state: UserFormState) {
|
|
145
|
+
const errors: Partial<Record<keyof UserFormState, string>> = {};
|
|
146
|
+
if (!state.name.trim()) errors.name = 'Required';
|
|
147
|
+
return errors;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Sharing Patterns
|
|
153
|
+
|
|
154
|
+
1. **Pattern A** (default): Parent ViewModel passes props to presentational children
|
|
155
|
+
2. **Pattern B**: Singleton ViewModel via `useSingleton` for app-wide state
|
|
156
|
+
3. **Pattern C**: Separate ViewModels sharing a singleton Collection
|
|
157
|
+
|
|
158
|
+
## ViewModel Events
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
interface SaveEvents { saved: { id: string }; error: void; }
|
|
162
|
+
|
|
163
|
+
class ItemVM extends ViewModel<State, SaveEvents> {
|
|
164
|
+
async save() {
|
|
165
|
+
const result = await this.service.save(this.state.draft, this.disposeSignal);
|
|
166
|
+
this.emit('saved', { id: result.id }); // protected, type-safe
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Component
|
|
171
|
+
useEvent(vm, 'saved', ({ id }) => toast.success(`Saved ${id}`));
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Optimistic Updates
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
async toggleStatus(id: string) {
|
|
178
|
+
const rollback = this.collection.optimistic(() => {
|
|
179
|
+
this.collection.update(id, { status: 'done' });
|
|
180
|
+
});
|
|
181
|
+
try {
|
|
182
|
+
await this.service.update(id, { status: 'done' }, this.disposeSignal);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
if (!isAbortError(e)) rollback();
|
|
185
|
+
throw e;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Error Handling Layers
|
|
191
|
+
|
|
192
|
+
1. **Async tracking** (automatic): Write happy path, read `vm.async.method.error`
|
|
193
|
+
2. **Imperative events** (explicit): try/catch + `emit()` + **re-throw**
|
|
194
|
+
3. **Error classification** (services): `throw HttpError`, `classifyError()`
|
|
195
|
+
|
|
196
|
+
## Testing
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
beforeEach(() => teardownAll());
|
|
200
|
+
|
|
201
|
+
test('example', () => {
|
|
202
|
+
const vm = new MyViewModel({ items: [], search: '' });
|
|
203
|
+
vm.init();
|
|
204
|
+
vm.setSearch('test');
|
|
205
|
+
expect(vm.filtered).toHaveLength(1);
|
|
206
|
+
vm.dispose();
|
|
207
|
+
});
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Anti-Patterns — NEVER Do These
|
|
211
|
+
|
|
212
|
+
- Loading/error flags in State → use `vm.async`
|
|
213
|
+
- Derived state in State → use getters
|
|
214
|
+
- `set()` inside a getter → infinite loop
|
|
215
|
+
- `useEffect` for data loading → use `onInit()`
|
|
216
|
+
- Multiple `useLocal` in one component → split components
|
|
217
|
+
- Components importing Collections/Services → only ViewModel + hooks
|
|
218
|
+
- Services holding state/cache → use Collections
|
|
219
|
+
- Swallowing errors without re-throw → breaks async tracking
|
|
220
|
+
- Manual try/catch for standard loads → async tracking handles it
|
|
221
|
+
- Two-step setters with refilter calls → getters auto-recompute
|
|
222
|
+
- Manual optimistic snapshot/restore → use `collection.optimistic()`
|
|
223
|
+
- `useState`/`useMemo`/`useCallback` in connected components → ViewModel handles it
|
|
224
|
+
|
|
225
|
+
## Decision Framework
|
|
226
|
+
|
|
227
|
+
- Holds UI state for a component → **ViewModel**
|
|
228
|
+
- Single entity with validation → **Model**
|
|
229
|
+
- List of entities with CRUD → **Collection**
|
|
230
|
+
- Fetches external data → **Service**
|
|
231
|
+
- Cross-cutting events → **EventBus**
|
|
232
|
+
- Persistent connection → **Channel**
|
|
233
|
+
- Coordinates multiple ViewModels → **Controller** (rare)
|
|
234
|
+
|
|
235
|
+
## Dev Mode
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
// vite.config.ts
|
|
239
|
+
define: { __MVC_KIT_DEV__: process.env.NODE_ENV !== 'production' }
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Catches: `set()` inside getter, ghost async ops, method call after dispose, reserved key override.
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# mvc-kit Framework Rules
|
|
2
|
+
|
|
3
|
+
This project uses **mvc-kit**, a zero-dependency TypeScript-first reactive state management library for React. Follow these rules strictly when writing or reviewing code.
|
|
4
|
+
|
|
5
|
+
## Core Classes
|
|
6
|
+
|
|
7
|
+
| Class | Role | Scope |
|
|
8
|
+
|-------|------|-------|
|
|
9
|
+
| `ViewModel<S, E?>` | Reactive state + computed getters + async tracking + typed events | Component-scoped (`useLocal`) |
|
|
10
|
+
| `Model<S>` | Entity with validation + dirty tracking + commit/rollback | Component-scoped (`useModel`) |
|
|
11
|
+
| `Collection<T>` | Reactive typed array, shared data cache, optimistic updates | Singleton |
|
|
12
|
+
| `Service` | Stateless infrastructure adapter (HTTP, storage) | Singleton |
|
|
13
|
+
| `EventBus<E>` | Typed pub/sub for cross-cutting events | Singleton |
|
|
14
|
+
| `Channel<M>` | Persistent connection (WebSocket/SSE) with auto-reconnect | Singleton |
|
|
15
|
+
| `Controller` | Stateless multi-ViewModel orchestrator (rare) | Component-scoped |
|
|
16
|
+
|
|
17
|
+
## Imports
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { ViewModel, Model, Collection, Controller, Service, EventBus, Channel } from 'mvc-kit';
|
|
21
|
+
import { singleton, teardownAll, HttpError, isAbortError, classifyError } from 'mvc-kit';
|
|
22
|
+
import { useLocal, useSingleton, useInstance, useModel, useField, useEvent, useEmit, useResolve, useTeardown, Provider } from 'mvc-kit/react';
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Architecture Rules
|
|
26
|
+
|
|
27
|
+
1. **State = source of truth only.** No derived values, no loading/error flags in state interfaces.
|
|
28
|
+
2. **Derived values are `get` accessors** on the ViewModel. Auto-memoized after `init()`.
|
|
29
|
+
3. **Async status is automatic.** After `init()`, `vm.async.methodName` returns `{ loading, error, errorCode }`. Never store loading/error in state.
|
|
30
|
+
4. **One ViewModel per component** via `useLocal`. No `useEffect` for data loading — use `onInit()`.
|
|
31
|
+
5. **No `useState`/`useMemo`/`useCallback`** — the ViewModel is the hook.
|
|
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.
|
|
34
|
+
8. **Services are stateless.** Accept `AbortSignal`, throw `HttpError`, no knowledge of ViewModels.
|
|
35
|
+
9. **Pass `this.disposeSignal`** to every async call.
|
|
36
|
+
10. **Lifecycle**: `construct → init() → use → dispose()`. Hooks auto-call `init()`.
|
|
37
|
+
|
|
38
|
+
## ViewModel Pattern
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
interface ItemState {
|
|
42
|
+
items: Item[];
|
|
43
|
+
search: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class ItemsViewModel extends ViewModel<ItemState> {
|
|
47
|
+
// --- Private fields ---
|
|
48
|
+
private service = singleton(ItemService);
|
|
49
|
+
private collection = singleton(ItemsCollection);
|
|
50
|
+
|
|
51
|
+
// --- Computed getters ---
|
|
52
|
+
get filtered(): Item[] {
|
|
53
|
+
const { items, search } = this.state;
|
|
54
|
+
if (!search) return items;
|
|
55
|
+
const q = search.toLowerCase();
|
|
56
|
+
return items.filter(i => i.name.toLowerCase().includes(q));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get total(): number { return this.state.items.length; }
|
|
60
|
+
|
|
61
|
+
// --- Lifecycle ---
|
|
62
|
+
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
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- Actions ---
|
|
74
|
+
async load() {
|
|
75
|
+
const data = await this.service.getAll(this.disposeSignal);
|
|
76
|
+
this.collection.reset(data);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- Setters ---
|
|
80
|
+
setSearch(search: string) { this.set({ search }); }
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Section order:** Private fields → Computed getters → Lifecycle → Actions → Setters.
|
|
85
|
+
|
|
86
|
+
## Component Pattern
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
function ItemsPage() {
|
|
90
|
+
const [state, vm] = useLocal(ItemsViewModel, { items: [], search: '' });
|
|
91
|
+
const { loading, error } = vm.async.load;
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div>
|
|
95
|
+
<input value={state.search} onChange={e => vm.setSearch(e.target.value)} />
|
|
96
|
+
{loading && <Spinner />}
|
|
97
|
+
{error && <ErrorBanner message={error} />}
|
|
98
|
+
<ItemsTable items={vm.filtered} />
|
|
99
|
+
<p>Showing {vm.filtered.length} of {vm.total}</p>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## React Hooks
|
|
106
|
+
|
|
107
|
+
| Hook | Usage |
|
|
108
|
+
|------|-------|
|
|
109
|
+
| `useLocal(Class, ...args)` | Component-scoped, auto-init/dispose. Returns `[state, instance]`. |
|
|
110
|
+
| `useSingleton(Class, ...args)` | Singleton, shared state. Returns `[state, instance]`. |
|
|
111
|
+
| `useInstance(subscribable)` | Subscribe to existing instance. Returns `state`. |
|
|
112
|
+
| `useModel(factory)` | Model with `{ state, errors, valid, dirty, model }`. |
|
|
113
|
+
| `useField(model, key)` | Single field: `{ value, error, set }`. |
|
|
114
|
+
| `useEvent(source, event, handler)` | EventBus/ViewModel event subscription. |
|
|
115
|
+
| `useEmit(bus)` | Stable emit function. |
|
|
116
|
+
| `useResolve(Class)` | Resolve from Provider or singleton. |
|
|
117
|
+
| `useTeardown(...Classes)` | Teardown singletons on unmount. |
|
|
118
|
+
|
|
119
|
+
## Service Pattern
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
class UserService extends Service {
|
|
123
|
+
async getAll(signal?: AbortSignal): Promise<User[]> {
|
|
124
|
+
const res = await fetch('/api/users', { signal });
|
|
125
|
+
if (!res.ok) throw new HttpError(res.status, res.statusText);
|
|
126
|
+
return res.json();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Collection Pattern
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
class UsersCollection extends Collection<UserState> {}
|
|
135
|
+
// Thin subclass. Query logic in ViewModel getters.
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Model Pattern
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
class UserModel extends Model<UserFormState> {
|
|
142
|
+
setName(name: string) { this.set({ name }); }
|
|
143
|
+
|
|
144
|
+
protected validate(state: UserFormState) {
|
|
145
|
+
const errors: Partial<Record<keyof UserFormState, string>> = {};
|
|
146
|
+
if (!state.name.trim()) errors.name = 'Required';
|
|
147
|
+
return errors;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Sharing Patterns
|
|
153
|
+
|
|
154
|
+
1. **Pattern A** (default): Parent ViewModel passes props to presentational children
|
|
155
|
+
2. **Pattern B**: Singleton ViewModel via `useSingleton` for app-wide state
|
|
156
|
+
3. **Pattern C**: Separate ViewModels sharing a singleton Collection
|
|
157
|
+
|
|
158
|
+
## ViewModel Events
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
interface SaveEvents { saved: { id: string }; error: void; }
|
|
162
|
+
|
|
163
|
+
class ItemVM extends ViewModel<State, SaveEvents> {
|
|
164
|
+
async save() {
|
|
165
|
+
const result = await this.service.save(this.state.draft, this.disposeSignal);
|
|
166
|
+
this.emit('saved', { id: result.id }); // protected, type-safe
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Component
|
|
171
|
+
useEvent(vm, 'saved', ({ id }) => toast.success(`Saved ${id}`));
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Optimistic Updates
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
async toggleStatus(id: string) {
|
|
178
|
+
const rollback = this.collection.optimistic(() => {
|
|
179
|
+
this.collection.update(id, { status: 'done' });
|
|
180
|
+
});
|
|
181
|
+
try {
|
|
182
|
+
await this.service.update(id, { status: 'done' }, this.disposeSignal);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
if (!isAbortError(e)) rollback();
|
|
185
|
+
throw e;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Error Handling Layers
|
|
191
|
+
|
|
192
|
+
1. **Async tracking** (automatic): Write happy path, read `vm.async.method.error`
|
|
193
|
+
2. **Imperative events** (explicit): try/catch + `emit()` + **re-throw**
|
|
194
|
+
3. **Error classification** (services): `throw HttpError`, `classifyError()`
|
|
195
|
+
|
|
196
|
+
## Testing
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
beforeEach(() => teardownAll());
|
|
200
|
+
|
|
201
|
+
test('example', () => {
|
|
202
|
+
const vm = new MyViewModel({ items: [], search: '' });
|
|
203
|
+
vm.init();
|
|
204
|
+
vm.setSearch('test');
|
|
205
|
+
expect(vm.filtered).toHaveLength(1);
|
|
206
|
+
vm.dispose();
|
|
207
|
+
});
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Anti-Patterns — NEVER Do These
|
|
211
|
+
|
|
212
|
+
- Loading/error flags in State → use `vm.async`
|
|
213
|
+
- Derived state in State → use getters
|
|
214
|
+
- `set()` inside a getter → infinite loop
|
|
215
|
+
- `useEffect` for data loading → use `onInit()`
|
|
216
|
+
- Multiple `useLocal` in one component → split components
|
|
217
|
+
- Components importing Collections/Services → only ViewModel + hooks
|
|
218
|
+
- Services holding state/cache → use Collections
|
|
219
|
+
- Swallowing errors without re-throw → breaks async tracking
|
|
220
|
+
- Manual try/catch for standard loads → async tracking handles it
|
|
221
|
+
- Two-step setters with refilter calls → getters auto-recompute
|
|
222
|
+
- Manual optimistic snapshot/restore → use `collection.optimistic()`
|
|
223
|
+
- `useState`/`useMemo`/`useCallback` in connected components → ViewModel handles it
|
|
224
|
+
|
|
225
|
+
## Decision Framework
|
|
226
|
+
|
|
227
|
+
- Holds UI state for a component → **ViewModel**
|
|
228
|
+
- Single entity with validation → **Model**
|
|
229
|
+
- List of entities with CRUD → **Collection**
|
|
230
|
+
- Fetches external data → **Service**
|
|
231
|
+
- Cross-cutting events → **EventBus**
|
|
232
|
+
- Persistent connection → **Channel**
|
|
233
|
+
- Coordinates multiple ViewModels → **Controller** (rare)
|
|
234
|
+
|
|
235
|
+
## Dev Mode
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
// vite.config.ts
|
|
239
|
+
define: { __MVC_KIT_DEV__: process.env.NODE_ENV !== 'production' }
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Catches: `set()` inside getter, ghost async ops, method call after dispose, reserved key override.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mvc-kit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Zero-magic, class-based reactive ViewModel library",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/mvc-kit.cjs",
|
|
@@ -29,8 +29,12 @@
|
|
|
29
29
|
}
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
|
-
"dist"
|
|
32
|
+
"dist",
|
|
33
|
+
"agent-config"
|
|
33
34
|
],
|
|
35
|
+
"bin": {
|
|
36
|
+
"mvc-kit-setup": "./agent-config/bin/setup.mjs"
|
|
37
|
+
},
|
|
34
38
|
"sideEffects": false,
|
|
35
39
|
"scripts": {
|
|
36
40
|
"dev": "vite",
|