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