mvc-kit 0.0.1 → 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.
Files changed (83) hide show
  1. package/README.md +827 -0
  2. package/agent-config/bin/setup.mjs +217 -0
  3. package/agent-config/claude-code/.claude-plugin/plugin.json +5 -0
  4. package/agent-config/claude-code/agents/mvc-kit-architect.md +97 -0
  5. package/agent-config/claude-code/skills/guide/SKILL.md +85 -0
  6. package/agent-config/claude-code/skills/guide/anti-patterns.md +321 -0
  7. package/agent-config/claude-code/skills/guide/api-reference.md +310 -0
  8. package/agent-config/claude-code/skills/guide/patterns.md +336 -0
  9. package/agent-config/claude-code/skills/review/SKILL.md +53 -0
  10. package/agent-config/claude-code/skills/review/checklist.md +89 -0
  11. package/agent-config/claude-code/skills/scaffold/SKILL.md +52 -0
  12. package/agent-config/claude-code/skills/scaffold/templates/channel.md +88 -0
  13. package/agent-config/claude-code/skills/scaffold/templates/collection.md +49 -0
  14. package/agent-config/claude-code/skills/scaffold/templates/controller.md +56 -0
  15. package/agent-config/claude-code/skills/scaffold/templates/eventbus.md +54 -0
  16. package/agent-config/claude-code/skills/scaffold/templates/model.md +102 -0
  17. package/agent-config/claude-code/skills/scaffold/templates/page-component.md +58 -0
  18. package/agent-config/claude-code/skills/scaffold/templates/service.md +101 -0
  19. package/agent-config/claude-code/skills/scaffold/templates/viewmodel.md +128 -0
  20. package/agent-config/copilot/copilot-instructions.md +242 -0
  21. package/agent-config/cursor/cursorrules +242 -0
  22. package/dist/Channel.d.ts +51 -0
  23. package/dist/Channel.d.ts.map +1 -0
  24. package/dist/Collection.d.ts +82 -0
  25. package/dist/Collection.d.ts.map +1 -0
  26. package/dist/Controller.d.ts +21 -0
  27. package/dist/Controller.d.ts.map +1 -0
  28. package/dist/EventBus.d.ts +32 -0
  29. package/dist/EventBus.d.ts.map +1 -0
  30. package/dist/Model.d.ts +58 -0
  31. package/dist/Model.d.ts.map +1 -0
  32. package/dist/Service.d.ts +20 -0
  33. package/dist/Service.d.ts.map +1 -0
  34. package/dist/ViewModel.d.ts +119 -0
  35. package/dist/ViewModel.d.ts.map +1 -0
  36. package/dist/errors.d.ts +26 -0
  37. package/dist/errors.d.ts.map +1 -0
  38. package/dist/index.d.ts +14 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/mvc-kit.cjs +2 -0
  41. package/dist/mvc-kit.cjs.map +1 -0
  42. package/dist/mvc-kit.js +1019 -0
  43. package/dist/mvc-kit.js.map +1 -0
  44. package/dist/react/guards.d.ts +8 -0
  45. package/dist/react/guards.d.ts.map +1 -0
  46. package/dist/react/index.d.ts +11 -0
  47. package/dist/react/index.d.ts.map +1 -0
  48. package/dist/react/provider.d.ts +14 -0
  49. package/dist/react/provider.d.ts.map +1 -0
  50. package/dist/react/types.d.ts +18 -0
  51. package/dist/react/types.d.ts.map +1 -0
  52. package/dist/react/use-event-bus.d.ts +13 -0
  53. package/dist/react/use-event-bus.d.ts.map +1 -0
  54. package/dist/react/use-instance.d.ts +11 -0
  55. package/dist/react/use-instance.d.ts.map +1 -0
  56. package/dist/react/use-local.d.ts +42 -0
  57. package/dist/react/use-local.d.ts.map +1 -0
  58. package/dist/react/use-model.d.ts +24 -0
  59. package/dist/react/use-model.d.ts.map +1 -0
  60. package/dist/react/use-singleton.d.ts +13 -0
  61. package/dist/react/use-singleton.d.ts.map +1 -0
  62. package/dist/react/use-teardown.d.ts +7 -0
  63. package/dist/react/use-teardown.d.ts.map +1 -0
  64. package/dist/react.cjs +15 -0
  65. package/dist/react.cjs.map +1 -0
  66. package/dist/react.js +758 -0
  67. package/dist/react.js.map +1 -0
  68. package/dist/singleton-C8_FRbA7.js +85 -0
  69. package/dist/singleton-C8_FRbA7.js.map +1 -0
  70. package/dist/singleton-L-u2W_lX.cjs +2 -0
  71. package/dist/singleton-L-u2W_lX.cjs.map +1 -0
  72. package/dist/singleton.d.ts +9 -0
  73. package/dist/singleton.d.ts.map +1 -0
  74. package/dist/types.d.ts +44 -0
  75. package/dist/types.d.ts.map +1 -0
  76. package/package.json +56 -5
  77. package/index.html +0 -13
  78. package/public/vite.svg +0 -1
  79. package/src/counter.ts +0 -9
  80. package/src/main.ts +0 -24
  81. package/src/style.css +0 -96
  82. package/src/typescript.svg +0 -1
  83. package/tsconfig.json +0 -26
package/README.md ADDED
@@ -0,0 +1,827 @@
1
+ # mvc-kit
2
+
3
+ <img src="./mvc-kit-logo.jpg" alt="mvc-kit logo" width="300" />
4
+
5
+ Zero-
6
+
7
+ - **Tiny:** ~2KB min+gzip (core), ~7KB with React
8
+ - **Zero dependencies**
9
+ - **Framework-agnostic core**
10
+ - **TypeScript-first**
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ npm install mvc-kit
16
+ ```
17
+
18
+ ## AI Agent Plugin
19
+
20
+ mvc-kit ships with built-in context for AI coding assistants — stays in sync with your installed version automatically.
21
+
22
+ ```bash
23
+ npx mvc-kit-setup # Set up all agents
24
+ npx mvc-kit-setup cursor # Cursor only
25
+ npx mvc-kit-setup copilot # GitHub Copilot only
26
+ ```
27
+
28
+ **Claude Code** — load directly as a plugin (no file copying, auto-updates):
29
+ ```bash
30
+ claude --plugin-dir node_modules/mvc-kit/agent-config/claude-code
31
+ ```
32
+
33
+ Skills: `/mvc-kit:guide` (framework reference), `/mvc-kit:scaffold <type> <Name>` (code generation), `/mvc-kit:review <path>` (pattern review).
34
+
35
+ **Cursor** — writes `.cursorrules`. **Copilot** — writes `.github/copilot-instructions.md`. Both use idempotent markers, safe to re-run.
36
+
37
+ ## Quick Start
38
+
39
+ ```typescript
40
+ import { ViewModel } from 'mvc-kit';
41
+
42
+ interface CounterState {
43
+ count: number;
44
+ }
45
+
46
+ class CounterViewModel extends ViewModel<CounterState> {
47
+ increment() {
48
+ this.set({ count: this.state.count + 1 });
49
+ }
50
+ }
51
+
52
+ const counter = new CounterViewModel({ count: 0 });
53
+ counter.subscribe((state, prev) => console.log(state.count));
54
+ counter.increment(); // logs: 1
55
+ ```
56
+
57
+ ## Core Classes
58
+
59
+ ### ViewModel
60
+
61
+ Reactive state container. Extend and call `set()` to update state.
62
+
63
+ ```typescript
64
+ class TodosViewModel extends ViewModel<{ items: string[] }> {
65
+ addItem(item: string) {
66
+ this.set(prev => ({ items: [...prev.items, item] }));
67
+ }
68
+
69
+ // Called once after init() — use for subscriptions, data fetching, etc.
70
+ protected onInit() {
71
+ this.loadItems();
72
+ }
73
+
74
+ // Called after every state change
75
+ protected onSet(prev: Readonly<{ items: string[] }>, next: Readonly<{ items: string[] }>) {
76
+ console.log('Items changed:', prev.items.length, '→', next.items.length);
77
+ }
78
+
79
+ protected onDispose() {
80
+ // Cleanup logic
81
+ }
82
+
83
+ // After init(), async methods are automatically tracked:
84
+ // vm.async.loadItems → { loading: boolean, error: string | null }
85
+ async loadItems() {
86
+ // this.disposeSignal is automatically aborted on dispose — fetch() will throw AbortError
87
+ const res = await fetch('/api/items', { signal: this.disposeSignal });
88
+ const items = await res.json();
89
+ this.set({ items });
90
+ }
91
+ }
92
+ ```
93
+
94
+ ### Model
95
+
96
+ Reactive entity with validation and dirty tracking.
97
+
98
+ ```typescript
99
+ class UserModel extends Model<{ name: string; email: string }> {
100
+ setName(name: string) {
101
+ this.set({ name });
102
+ }
103
+
104
+ protected validate(state: { name: string; email: string }) {
105
+ const errors: Partial<Record<keyof typeof state, string>> = {};
106
+ if (!state.name) errors.name = 'Name is required';
107
+ if (!state.email.includes('@')) errors.email = 'Invalid email';
108
+ return errors;
109
+ }
110
+ }
111
+
112
+ const user = new UserModel({ name: '', email: '' });
113
+ console.log(user.valid); // false
114
+ console.log(user.errors); // { name: 'Name is required', email: 'Invalid email' }
115
+
116
+ user.setName('John');
117
+ console.log(user.dirty); // true (differs from committed state)
118
+
119
+ user.commit(); // Mark current state as baseline
120
+ user.rollback(); // Revert to committed state
121
+ ```
122
+
123
+ ### Collection
124
+
125
+ Reactive typed array with CRUD and query methods.
126
+
127
+ ```typescript
128
+ interface Todo {
129
+ id: string;
130
+ text: string;
131
+ done: boolean;
132
+ }
133
+
134
+ const todos = new Collection<Todo>();
135
+
136
+ // CRUD (triggers re-renders)
137
+ todos.add({ id: '1', text: 'Learn mvc-kit', done: false });
138
+ todos.update('1', { done: true });
139
+ todos.remove('1');
140
+ todos.reset([...]); // Replace all
141
+ todos.clear();
142
+
143
+ // Optimistic update with rollback
144
+ const rollback = todos.optimistic(() => {
145
+ todos.update('1', { done: true });
146
+ });
147
+ // On failure: rollback() restores pre-update state
148
+
149
+ // Properties
150
+ todos.items; // readonly T[] (same as state)
151
+ todos.length; // number of items
152
+
153
+ // Query (pure, no notifications)
154
+ todos.get('1'); // Get by id (O(1) via internal index)
155
+ todos.has('1'); // Check existence
156
+ todos.find(t => t.done); // Find first match
157
+ todos.filter(t => !t.done);
158
+ todos.sorted((a, b) => a.text.localeCompare(b.text));
159
+ todos.map(t => t.text); // Map to new array
160
+ ```
161
+
162
+ ### Controller
163
+
164
+ Stateless orchestrator for complex logic. Component-scoped, auto-disposed.
165
+
166
+ ```typescript
167
+ class CheckoutController extends Controller {
168
+ constructor(
169
+ private cart: CartViewModel,
170
+ private api: ApiService
171
+ ) {
172
+ super();
173
+ }
174
+
175
+ // Called once after init() — set up subscriptions, wire dependencies
176
+ protected onInit() {
177
+ // subscribeTo registers auto-cleanup — no manual tracking needed
178
+ this.subscribeTo(this.cart, () => this.onCartChanged());
179
+ }
180
+
181
+ async submit() {
182
+ const items = this.cart.state.items;
183
+ // this.disposeSignal auto-cancels the request if the controller is disposed mid-flight
184
+ await this.api.checkout(items, { signal: this.disposeSignal });
185
+ this.cart.clear();
186
+ }
187
+ }
188
+ ```
189
+
190
+ ### Service
191
+
192
+ Non-reactive infrastructure service. Singleton-scoped.
193
+
194
+ ```typescript
195
+ class ApiService extends Service {
196
+ async fetchUser(id: string) {
197
+ // this.disposeSignal auto-cancels if the service is disposed
198
+ const res = await fetch(`/api/users/${id}`, { signal: this.disposeSignal });
199
+ return res.json();
200
+ }
201
+ }
202
+ ```
203
+
204
+ ### EventBus
205
+
206
+ Typed pub/sub event bus.
207
+
208
+ ```typescript
209
+ interface AppEvents {
210
+ 'user:login': { userId: string };
211
+ 'user:logout': void;
212
+ 'notification': { message: string };
213
+ }
214
+
215
+ const bus = new EventBus<AppEvents>();
216
+
217
+ // Subscribe
218
+ const unsubscribe = bus.on('user:login', ({ userId }) => {
219
+ console.log(`User ${userId} logged in`);
220
+ });
221
+
222
+ // One-time subscription
223
+ bus.once('notification', ({ message }) => alert(message));
224
+
225
+ // Emit
226
+ bus.emit('user:login', { userId: '123' });
227
+
228
+ // Cleanup
229
+ unsubscribe();
230
+ bus.dispose();
231
+ ```
232
+
233
+ ### ViewModel Events
234
+
235
+ ViewModels support an optional second generic parameter for typed events — fire-and-forget signals for toasts, navigation, animations, etc.
236
+
237
+ ```typescript
238
+ interface SaveEvents {
239
+ saved: { id: string };
240
+ error: { message: string };
241
+ }
242
+
243
+ class TodoVM extends ViewModel<TodoState, SaveEvents> {
244
+ async save() {
245
+ try {
246
+ const result = await this.api.save(this.state);
247
+ this.emit('saved', { id: result.id }); // protected, type-safe
248
+ } catch {
249
+ this.emit('error', { message: 'Save failed' });
250
+ }
251
+ }
252
+ }
253
+
254
+ // React — subscribe directly on the ViewModel
255
+ const [state, vm] = useLocal(TodoVM);
256
+ useEvent(vm, 'saved', ({ id }) => toast.success(`Saved ${id}`));
257
+ ```
258
+
259
+ - `events` getter is lazy (zero cost if never accessed)
260
+ - `emit()` is protected — only the ViewModel can emit
261
+ - Event bus auto-disposes with the ViewModel
262
+ - When `E` is omitted (default `{}`), everything works as before (backward-compatible)
263
+
264
+ ### Async Tracking
265
+
266
+ After `init()`, ViewModel automatically tracks loading and error state for every async method — no manual boilerplate needed.
267
+
268
+ **Before (manual tracking):**
269
+ ```typescript
270
+ class UsersVM extends ViewModel<{ users: User[]; loading: boolean; error: string | null }> {
271
+ async fetchUsers() {
272
+ this.set({ loading: true, error: null });
273
+ try {
274
+ const res = await fetch('/api/users', { signal: this.disposeSignal });
275
+ this.set({ users: await res.json(), loading: false });
276
+ } catch (e) {
277
+ if (isAbortError(e)) return;
278
+ this.set({ loading: false, error: e.message });
279
+ throw e;
280
+ }
281
+ }
282
+ }
283
+ ```
284
+
285
+ **After (automatic tracking):**
286
+ ```typescript
287
+ class UsersVM extends ViewModel<{ users: User[] }> {
288
+ async fetchUsers() {
289
+ const res = await fetch('/api/users', { signal: this.disposeSignal });
290
+ this.set({ users: await res.json() });
291
+ }
292
+ }
293
+
294
+ const vm = new UsersVM({ users: [] });
295
+ vm.init();
296
+
297
+ // Automatic — no manual loading/error state needed
298
+ vm.async.fetchUsers // → { loading: false, error: null }
299
+
300
+ vm.fetchUsers();
301
+ vm.async.fetchUsers // → { loading: true, error: null }
302
+
303
+ await vm.fetchUsers();
304
+ vm.async.fetchUsers // → { loading: false, error: null }
305
+ ```
306
+
307
+ #### TaskState
308
+
309
+ ```typescript
310
+ interface TaskState {
311
+ readonly loading: boolean;
312
+ readonly error: string | null;
313
+ }
314
+ ```
315
+
316
+ Each method key in `vm.async` returns a frozen `TaskState` snapshot. Unknown keys return the default `{ loading: false, error: null }`.
317
+
318
+ #### Concurrent calls
319
+
320
+ Loading state is counter-based. If you call the same method multiple times concurrently, `loading` stays `true` until all calls complete:
321
+
322
+ ```typescript
323
+ vm.fetchUsers(); // loading: true (count: 1)
324
+ vm.fetchUsers(); // loading: true (count: 2)
325
+ // first resolves → loading: true (count: 1)
326
+ // second resolves → loading: false (count: 0)
327
+ ```
328
+
329
+ #### Error handling
330
+
331
+ - **Normal errors** are captured in `TaskState.error` (as a string message) AND re-thrown — standard Promise rejection behavior is preserved
332
+ - **AbortErrors** are silently swallowed — not captured in `TaskState.error`, not re-thrown
333
+
334
+ #### Sync method pruning
335
+
336
+ Sync methods are auto-detected on first call. If a method returns a non-thenable, its wrapper is replaced with a direct `bind()` — zero overhead after the first call.
337
+
338
+ #### `subscribeAsync(listener)`
339
+
340
+ Low-level subscription for async state changes. Mirrors the `subscribe()` contract. Used internally by `useInstance()` to trigger React re-renders when async status changes.
341
+
342
+ ```typescript
343
+ const unsub = vm.subscribeAsync(() => {
344
+ console.log(vm.async.fetchUsers);
345
+ });
346
+ ```
347
+
348
+ #### Reserved keys
349
+
350
+ `async` and `subscribeAsync` are reserved property names on ViewModel. Subclasses that define these as properties, methods, or getters throw immediately.
351
+
352
+ #### Ghost detection (DEV)
353
+
354
+ After `dispose()`, if async methods had pending calls, a warning is logged after `GHOST_TIMEOUT` (default 3s, configurable via `static GHOST_TIMEOUT`):
355
+
356
+ ```
357
+ [mvc-kit] Ghost async operation detected: "fetchUsers" had 1 pending call(s)
358
+ when the ViewModel was disposed. Consider using disposeSignal to cancel in-flight work.
359
+ ```
360
+
361
+ ### Error Utilities
362
+
363
+ Composable error handling utilities for consistent error classification.
364
+
365
+ ```typescript
366
+ import { isAbortError, classifyError, HttpError } from 'mvc-kit';
367
+
368
+ // In services — throw typed HTTP errors
369
+ if (!res.ok) throw new HttpError(res.status, res.statusText);
370
+
371
+ // In ViewModels — replace verbose AbortError checks
372
+ if (isAbortError(e)) return;
373
+
374
+ // Classify any error into a canonical shape
375
+ const appError = classifyError(error);
376
+ // appError.code: 'unauthorized' | 'network' | 'timeout' | 'abort' | ...
377
+ ```
378
+
379
+ ## Signal & Cleanup
380
+
381
+ Every class in mvc-kit has a built-in `AbortSignal` and cleanup registration system. This eliminates the need to manually track timers, subscriptions, and in-flight requests.
382
+
383
+ ### `disposeSignal` (public)
384
+
385
+ A lazily-created `AbortSignal` that is automatically aborted when `dispose()` is called. Zero overhead if never accessed.
386
+
387
+ ```typescript
388
+ class ChatViewModel extends ViewModel<{ messages: Message[] }> {
389
+ protected onInit() {
390
+ this.loadMessages();
391
+ }
392
+
393
+ private async loadMessages() {
394
+ // fetch() throws AbortError if disposeSignal is aborted — no need for defensive checks after await
395
+ const res = await fetch('/api/messages', { signal: this.disposeSignal });
396
+ const messages = await res.json();
397
+ this.set({ messages });
398
+ }
399
+ }
400
+ ```
401
+
402
+ ### `subscribeTo(source, listener)` (protected)
403
+
404
+ Subscribe to a Subscribable and auto-unsubscribe on dispose. Available on ViewModel, Model, and Controller.
405
+
406
+ ```typescript
407
+ class UsersViewModel extends ViewModel<State> {
408
+ protected onInit() {
409
+ // Replaces: this.addCleanup(this.collection.subscribe(() => this.derive()))
410
+ this.subscribeTo(this.collection, () => this.derive());
411
+ }
412
+ }
413
+ ```
414
+
415
+ ### `addCleanup(fn)` (protected)
416
+
417
+ Register a teardown callback that fires on `dispose()`, after disposeSignal abort but before `onDispose()`. No manual bookkeeping needed.
418
+
419
+ ```typescript
420
+ class DashboardController extends Controller {
421
+ protected onInit() {
422
+ // Subscriptions are automatically cleaned up on dispose
423
+ const unsub = someStore.subscribe(() => this.refresh());
424
+ this.addCleanup(unsub);
425
+
426
+ // Timers too
427
+ const id = setInterval(() => this.poll(), 5000);
428
+ this.addCleanup(() => clearInterval(id));
429
+ }
430
+ }
431
+ ```
432
+
433
+ ### Disposal order
434
+
435
+ ```
436
+ _disposed = true → disposeSignal.abort() → addCleanup callbacks → onDispose() → clear internal data
437
+ ```
438
+
439
+ - Signal aborts **before** `onDispose()`, so `this.disposeSignal.aborted === true` inside `onDispose()`
440
+ - Cleanup callbacks fire in registration order
441
+ - If `disposeSignal` was never accessed, the abort step is skipped (zero cost)
442
+ - Re-created singletons after `teardown()` get a fresh, un-aborted disposeSignal
443
+
444
+ ### Composing signals
445
+
446
+ For per-call cancellation (e.g., rapid room switching), compose with `AbortSignal.any()`:
447
+
448
+ ```typescript
449
+ class ChatService extends Service {
450
+ async loadRoom(roomId: string, callSignal: AbortSignal) {
451
+ // Cancelled if EITHER the service is disposed OR the caller aborts
452
+ const res = await fetch(`/api/rooms/${roomId}`, {
453
+ signal: AbortSignal.any([this.disposeSignal, callSignal]),
454
+ });
455
+ return res.json();
456
+ }
457
+ }
458
+ ```
459
+
460
+ ## Singleton Registry
461
+
462
+ Manage shared instances globally.
463
+
464
+ ```typescript
465
+ import { singleton, hasSingleton, teardown, teardownAll } from 'mvc-kit';
466
+
467
+ // Get or create singleton
468
+ const api = singleton(ApiService);
469
+
470
+ // Check if singleton exists
471
+ hasSingleton(ApiService); // true
472
+
473
+ // Dispose specific singleton
474
+ teardown(ApiService);
475
+
476
+ // Dispose all singletons (useful in tests)
477
+ teardownAll();
478
+ ```
479
+
480
+ ## React Integration
481
+
482
+ ```typescript
483
+ import { useInstance, useLocal, useSingleton } from 'mvc-kit/react';
484
+ ```
485
+
486
+ ### Generic Hooks
487
+
488
+ #### `useInstance(subscribable)`
489
+
490
+ Subscribe to an existing Subscribable. No ownership - you manage disposal.
491
+
492
+ ```tsx
493
+ function Counter({ vm }: { vm: CounterViewModel }) {
494
+ const state = useInstance(vm);
495
+ return <div>{state.count}</div>;
496
+ }
497
+ ```
498
+
499
+ #### `useLocal(Class, ...args)` or `useLocal(factory)`
500
+
501
+ Create component-scoped instance. Auto-disposed on unmount. If the instance has an `onInit()` hook, it is called automatically after mount.
502
+
503
+ ```tsx
504
+ // Class-based
505
+ function Counter() {
506
+ const [state, vm] = useLocal(CounterViewModel, { count: 0 });
507
+ // vm.onInit() called automatically after mount — no useEffect needed
508
+ return <button onClick={() => vm.increment()}>{state.count}</button>;
509
+ }
510
+
511
+ // Factory-based (for complex initialization)
512
+ function Checkout() {
513
+ const controller = useLocal(() => new CheckoutController(cart, api));
514
+ return <button onClick={() => controller.submit()}>Submit</button>;
515
+ }
516
+ ```
517
+
518
+ **Return type:**
519
+ - Subscribable → `[state, instance]` tuple
520
+ - Disposable-only → `instance`
521
+
522
+ #### `useSingleton(Class, ...args)`
523
+
524
+ Get singleton instance. Registry manages lifecycle. Calls `init()` automatically after mount.
525
+
526
+ ```tsx
527
+ // Subscribable singleton
528
+ function UserProfile() {
529
+ const [state, vm] = useSingleton(UserViewModel);
530
+ return <div>{state.name}</div>;
531
+ }
532
+
533
+ // Service singleton
534
+ function Dashboard() {
535
+ const api = useSingleton(ApiService);
536
+ // ...
537
+ }
538
+ ```
539
+
540
+ ### Model Hooks
541
+
542
+ #### `useModel(factory)`
543
+
544
+ Create component-scoped Model with validation and dirty state. Calls `init()` automatically after mount.
545
+
546
+ ```tsx
547
+ function UserForm() {
548
+ const { state, errors, valid, dirty, model } = useModel(() =>
549
+ new UserModel({ name: '', email: '' })
550
+ );
551
+
552
+ return (
553
+ <form>
554
+ <input
555
+ value={state.name}
556
+ onChange={e => model.setName(e.target.value)}
557
+ />
558
+ {errors.name && <span>{errors.name}</span>}
559
+ <button disabled={!valid}>Submit</button>
560
+ </form>
561
+ );
562
+ }
563
+ ```
564
+
565
+ #### `useField(model, key)`
566
+
567
+ Subscribe to a single field with surgical re-renders. The returned `set()` calls the Model's `set()` directly — use custom setter methods on the Model for any logic beyond simple assignment.
568
+
569
+ ```tsx
570
+ function NameField({ model }: { model: UserModel }) {
571
+ const { value, error, set } = useField(model, 'name');
572
+
573
+ return (
574
+ <div>
575
+ <input value={value} onChange={e => set(e.target.value)} />
576
+ {error && <span>{error}</span>}
577
+ </div>
578
+ );
579
+ }
580
+ ```
581
+
582
+ ### EventBus Hooks
583
+
584
+ #### `useEvent(bus, event, handler)`
585
+
586
+ Subscribe to event, auto-unsubscribes on unmount.
587
+
588
+ ```tsx
589
+ function NotificationToast() {
590
+ const [message, setMessage] = useState('');
591
+
592
+ useEvent(bus, 'notification', ({ message }) => {
593
+ setMessage(message);
594
+ });
595
+
596
+ return message ? <div>{message}</div> : null;
597
+ }
598
+ ```
599
+
600
+ #### `useEmit(bus)`
601
+
602
+ Get stable emit function.
603
+
604
+ ```tsx
605
+ function LoginButton() {
606
+ const emit = useEmit(bus);
607
+
608
+ return (
609
+ <button onClick={() => emit('user:login', { userId: '123' })}>
610
+ Login
611
+ </button>
612
+ );
613
+ }
614
+ ```
615
+
616
+ ### DI & Testing
617
+
618
+ #### `Provider` and `useResolve`
619
+
620
+ Dependency injection for testing and Storybook.
621
+
622
+ ```tsx
623
+ // In tests/stories
624
+ <Provider provide={[
625
+ [ApiService, mockApi],
626
+ [UserViewModel, mockUserVM]
627
+ ]}>
628
+ <MyComponent />
629
+ </Provider>
630
+
631
+ // In components - falls back to singleton() if no Provider
632
+ function MyComponent() {
633
+ const api = useResolve(ApiService);
634
+ // ...
635
+ }
636
+ ```
637
+
638
+ #### `useTeardown(...Classes)`
639
+
640
+ Teardown singletons on unmount.
641
+
642
+ ```tsx
643
+ function App() {
644
+ // Clean up these singletons when App unmounts
645
+ useTeardown(UserViewModel, CartViewModel);
646
+
647
+ return <Main />;
648
+ }
649
+ ```
650
+
651
+ ## API Reference
652
+
653
+ ### Core Classes
654
+
655
+ | Class | Description |
656
+ |-------|-------------|
657
+ | `ViewModel<S, E?>` | Reactive state container with optional typed events |
658
+ | `Model<S>` | Reactive entity with validation/dirty tracking |
659
+ | `Collection<T>` | Reactive typed array with CRUD |
660
+ | `Controller` | Stateless orchestrator (Disposable) |
661
+ | `Service` | Non-reactive infrastructure service (Disposable) |
662
+ | `EventBus<E>` | Typed pub/sub event bus |
663
+
664
+ ### Interfaces
665
+
666
+ | Interface | Description |
667
+ |-----------|-------------|
668
+ | `Subscribable<S>` | Has `state`, `subscribe()`, `dispose()`, `disposeSignal` |
669
+ | `Disposable` | Has `disposed`, `disposeSignal`, `dispose()` |
670
+ | `Initializable` | Has `initialized`, `init()` |
671
+ | `Listener<S>` | `(state: Readonly<S>, prev: Readonly<S>) => void` |
672
+ | `Updater<S>` | `(state: Readonly<S>) => Partial<S>` |
673
+ | `ValidationErrors<S>` | `Partial<Record<keyof S, string>>` |
674
+ | `TaskState` | `{ loading: boolean; error: string \| null }` |
675
+ | `AsyncMethodKeys<T>` | Union of method names on `T` that return `Promise` |
676
+
677
+ ### Singleton Functions
678
+
679
+ | Function | Description |
680
+ |----------|-------------|
681
+ | `singleton(Class, ...args)` | Get or create singleton |
682
+ | `hasSingleton(Class)` | Check if singleton exists |
683
+ | `teardown(Class)` | Dispose and remove singleton |
684
+ | `teardownAll()` | Dispose all singletons |
685
+
686
+ ### Error Utilities
687
+
688
+ | Export | Description |
689
+ |--------|-------------|
690
+ | `AppError` (type) | Canonical error shape with typed `code` field |
691
+ | `HttpError` | Typed HTTP error class for services to throw |
692
+ | `isAbortError(error)` | Guard for AbortError DOMException |
693
+ | `classifyError(error)` | Maps raw errors → `AppError` |
694
+
695
+ ### React Hooks
696
+
697
+ | Hook | Description |
698
+ |------|-------------|
699
+ | `useInstance(subscribable)` | Subscribe to existing instance |
700
+ | `useLocal(Class \| factory, ...args)` | Component-scoped, auto-disposed, auto-init |
701
+ | `useSingleton(Class, ...args)` | Singleton, registry-managed, auto-init |
702
+ | `useModel(factory)` | Model with validation/dirty state, auto-init |
703
+ | `useField(model, key)` | Single field subscription |
704
+ | `useEvent(source, event, handler)` | Subscribe to EventBus or ViewModel event |
705
+ | `useEmit(bus)` | Get stable emit function |
706
+ | `useResolve(Class, ...args)` | Resolve from Provider or singleton |
707
+ | `useTeardown(...Classes)` | Teardown singletons on unmount |
708
+
709
+ ## Behavior Notes
710
+
711
+ - State is always shallow-frozen with `Object.freeze()`
712
+ - Updates are skipped if no values change (shallow equality)
713
+ - `dispose()` is idempotent (safe to call multiple times)
714
+ - `init()` is idempotent (safe to call multiple times, only runs `onInit()` once)
715
+ - On ViewModel, `set()` and `emit()` are no-ops after dispose (not throws) — allows in-flight async callbacks to resolve harmlessly and cleanup callbacks to emit final events
716
+ - Other mutation methods (`commit()`, `add()`, etc.) throw after dispose
717
+ - `subscribe()` / `on()` return a no-op unsubscriber after dispose (does not throw)
718
+ - Lifecycle: `construct → init → use → dispose`
719
+ - `onInit()` runs once after `init()` — supports sync and async (`void | Promise<void>`)
720
+ - `onSet(prev, next)` runs after every state change (ViewModel, Model)
721
+ - `onDispose()` runs once on dispose, after disposeSignal abort and cleanup callbacks
722
+ - `disposeSignal` is lazily created — zero memory/GC overhead unless accessed
723
+ - Disposal order: `_disposed = true → disposeSignal.abort() → addCleanup callbacks → onDispose() → clear internal data`
724
+ - All six core classes support `disposeSignal`, `addCleanup()`, and `onDispose()` (including EventBus and Collection)
725
+ - ViewModel, Model, and Controller also have `subscribeTo(source, listener)` for auto-cleaned subscriptions
726
+ - ViewModel has built-in typed events via optional second generic `E` — `events` getter, `emit()` method
727
+ - After `init()`, all subclass methods are wrapped for automatic async tracking; `vm.async.methodName` returns `TaskState`
728
+ - Sync methods are auto-pruned on first call — zero overhead after detection
729
+ - Async errors are re-thrown (preserves standard Promise rejection); AbortErrors are silently swallowed
730
+ - `async` and `subscribeAsync` are reserved property names on ViewModel
731
+ - React hooks (`useLocal`, `useModel`, `useSingleton`) auto-call `init()` after mount
732
+ - `singleton()` does **not** auto-call `init()` — call it manually outside React
733
+ - StrictMode safe: the `_initialized` guard prevents double-init during React's double-mount cycle; `disposeSignal` is not aborted during StrictMode's fake unmount/remount cycle
734
+ - `__MVC_KIT_DEV__` enables development-only safety checks (e.g., detecting `set()` inside getters). It defaults to `false` safely — no bundler config required. See [Dev Mode](#dev-mode-__mvc_kit_dev__).
735
+
736
+ ## Detailed Documentation
737
+
738
+ Each core class and React hook has a dedicated reference doc with full API details, usage patterns, and examples.
739
+
740
+ **Core Classes & Utilities**
741
+
742
+ | Doc | Description |
743
+ |-----|-------------|
744
+ | [ViewModel](src/ViewModel.md) | State management, computed getters, async tracking, typed events, lifecycle hooks |
745
+ | [Model](src/Model.md) | Validation, dirty tracking, commit/rollback for entity forms |
746
+ | [Collection](src/Collection.md) | Reactive typed array, CRUD, optimistic updates, shared data cache |
747
+ | [Controller](src/Controller.md) | Stateless orchestrator for multi-ViewModel coordination |
748
+ | [Service](src/Service.md) | Non-reactive infrastructure adapters (HTTP, storage, SDKs) |
749
+ | [EventBus](src/EventBus.md) | Typed pub/sub for cross-cutting event communication |
750
+ | [Channel](src/Channel.md) | Persistent connections (WebSocket, SSE) with auto-reconnect |
751
+ | [Singleton Registry](src/singleton.md) | Global instance management: `singleton()`, `teardown()`, `teardownAll()` |
752
+
753
+ **React Hooks**
754
+
755
+ | Doc | Description |
756
+ |-----|-------------|
757
+ | [useLocal](src/react/use-local.md) | Component-scoped instance, auto-init/dispose, deps array for recreate |
758
+ | [useInstance](src/react/use-instance.md) | Subscribe to an existing Subscribable (no lifecycle management) |
759
+ | [useSingleton](src/react/use-singleton.md) | Singleton resolution with auto-init and shared state |
760
+ | [useModel & useField](src/react/use-model.md) | Model binding with validation/dirty state; surgical per-field subscriptions |
761
+ | [useEvent & useEmit](src/react/use-event-bus.md) | Subscribe to and emit typed events from EventBus or ViewModel |
762
+ | [useTeardown](src/react/use-teardown.md) | Dispose singleton instances on component unmount |
763
+
764
+ ## Dev Mode (`__MVC_KIT_DEV__`)
765
+
766
+ mvc-kit includes development-only safety checks guarded by the `__MVC_KIT_DEV__` flag. When enabled, these checks catch common mistakes at development time with clear `console.error` messages instead of silent infinite loops or hard-to-debug failures.
767
+
768
+ The flag defaults to `false` safely — no bundler config is required. The library uses a `typeof` guard internally, so importing mvc-kit in Node, Deno, SSR, or any unbundled environment works without a `ReferenceError`.
769
+
770
+ ### Current checks
771
+
772
+ - **`set()` inside a getter** — After `init()`, ViewModel getters are auto-memoized and dependency-tracked. Calling `set()` from a getter creates an infinite loop (state change → getter recompute → `set()` → repeat). The dev guard detects this, logs an error, and prevents the `set()` call.
773
+ - **Ghost async operations** — After `dispose()`, if async methods had pending calls, a warning is logged after `GHOST_TIMEOUT` (default 3s). Suggests using `disposeSignal` to cancel in-flight work.
774
+ - **Method call after dispose** — Warning when calling a wrapped method after the ViewModel is disposed. The call is ignored and returns `undefined`.
775
+ - **Reserved key override** — Throws immediately if a subclass defines `async` or `subscribeAsync` as a property, method, or getter.
776
+ - **Method call before init** — Warning when calling a wrapped method before `init()`. The method still executes, but async tracking is not yet active.
777
+
778
+ ### Enabling dev mode
779
+
780
+ **With a bundler (recommended)**
781
+
782
+ Define `__MVC_KIT_DEV__` as `true` in your bundler's compile-time `define` config:
783
+
784
+ **Vite**
785
+ ```ts
786
+ // vite.config.ts
787
+ export default defineConfig({
788
+ define: { __MVC_KIT_DEV__: true },
789
+ // ...
790
+ });
791
+ ```
792
+
793
+ **Without a bundler**
794
+
795
+ Set the global before importing mvc-kit:
796
+
797
+ ```ts
798
+ globalThis.__MVC_KIT_DEV__ = true;
799
+ import { ViewModel } from 'mvc-kit';
800
+ ```
801
+
802
+ ### Production
803
+
804
+ Set `__MVC_KIT_DEV__` to `false` in your production config (or omit it entirely). The guarded code is dead-code-eliminated by minifiers, resulting in zero runtime cost.
805
+
806
+ ```ts
807
+ // vite.config.ts — production
808
+ export default defineConfig({
809
+ define: {
810
+ __MVC_KIT_DEV__: process.env.NODE_ENV !== 'production',
811
+ },
812
+ });
813
+ ```
814
+
815
+ ### How it works
816
+
817
+ Internally, mvc-kit resolves the flag once at module load:
818
+
819
+ ```ts
820
+ const __DEV__ = typeof __MVC_KIT_DEV__ !== 'undefined' && __MVC_KIT_DEV__;
821
+ ```
822
+
823
+ This is the same pattern used by Vue and Preact. Without a bundler, `typeof` returns `'undefined'` and the constant is `false` — safe, no crash. With a bundler `define`, the raw reference is replaced at build time: `const __DEV__ = true` (or `false`), and minifiers eliminate dead branches entirely.
824
+
825
+ ## License
826
+
827
+ MIT