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.
- package/README.md +808 -0
- package/dist/Channel.d.ts +51 -0
- package/dist/Channel.d.ts.map +1 -0
- package/dist/Collection.d.ts +82 -0
- package/dist/Collection.d.ts.map +1 -0
- package/dist/Controller.d.ts +21 -0
- package/dist/Controller.d.ts.map +1 -0
- package/dist/EventBus.d.ts +32 -0
- package/dist/EventBus.d.ts.map +1 -0
- package/dist/Model.d.ts +58 -0
- package/dist/Model.d.ts.map +1 -0
- package/dist/Service.d.ts +20 -0
- package/dist/Service.d.ts.map +1 -0
- package/dist/ViewModel.d.ts +119 -0
- package/dist/ViewModel.d.ts.map +1 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/mvc-kit.cjs +2 -0
- package/dist/mvc-kit.cjs.map +1 -0
- package/dist/mvc-kit.js +1019 -0
- package/dist/mvc-kit.js.map +1 -0
- package/dist/react/guards.d.ts +8 -0
- package/dist/react/guards.d.ts.map +1 -0
- package/dist/react/index.d.ts +11 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/provider.d.ts +14 -0
- package/dist/react/provider.d.ts.map +1 -0
- package/dist/react/types.d.ts +18 -0
- package/dist/react/types.d.ts.map +1 -0
- package/dist/react/use-event-bus.d.ts +13 -0
- package/dist/react/use-event-bus.d.ts.map +1 -0
- package/dist/react/use-instance.d.ts +11 -0
- package/dist/react/use-instance.d.ts.map +1 -0
- package/dist/react/use-local.d.ts +42 -0
- package/dist/react/use-local.d.ts.map +1 -0
- package/dist/react/use-model.d.ts +24 -0
- package/dist/react/use-model.d.ts.map +1 -0
- package/dist/react/use-singleton.d.ts +13 -0
- package/dist/react/use-singleton.d.ts.map +1 -0
- package/dist/react/use-teardown.d.ts +7 -0
- package/dist/react/use-teardown.d.ts.map +1 -0
- package/dist/react.cjs +15 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.js +758 -0
- package/dist/react.js.map +1 -0
- package/dist/singleton-C8_FRbA7.js +85 -0
- package/dist/singleton-C8_FRbA7.js.map +1 -0
- package/dist/singleton-L-u2W_lX.cjs +2 -0
- package/dist/singleton-L-u2W_lX.cjs.map +1 -0
- package/dist/singleton.d.ts +9 -0
- package/dist/singleton.d.ts.map +1 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +52 -5
- package/index.html +0 -13
- package/public/vite.svg +0 -1
- package/src/counter.ts +0 -9
- package/src/main.ts +0 -24
- package/src/style.css +0 -96
- package/src/typescript.svg +0 -1
- 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
|