mvc-kit 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,321 @@
1
+ # mvc-kit Anti-Patterns
2
+
3
+ Reject these patterns. Each entry shows the bad pattern and the correct alternative.
4
+
5
+ ---
6
+
7
+ ## 1. Loading/Error Flags in State
8
+
9
+ ```typescript
10
+ // BAD
11
+ interface State {
12
+ items: Item[];
13
+ loading: boolean;
14
+ error: string | null;
15
+ }
16
+
17
+ // GOOD — async tracking handles it
18
+ interface State {
19
+ items: Item[];
20
+ }
21
+ // Read: vm.async.load.loading, vm.async.load.error
22
+ ```
23
+
24
+ ---
25
+
26
+ ## 2. Stored Derived State
27
+
28
+ ```typescript
29
+ // BAD — manually syncing derived state
30
+ interface State {
31
+ items: Item[];
32
+ search: string;
33
+ filtered: Item[]; // derived value stored in state
34
+ }
35
+ setSearch(search: string) {
36
+ this.set({ search });
37
+ this.refilter(); // manual sync step
38
+ }
39
+
40
+ // GOOD — getter computes automatically
41
+ get filtered(): Item[] {
42
+ return this.state.items.filter(i => i.name.includes(this.state.search));
43
+ }
44
+ setSearch(search: string) { this.set({ search }); }
45
+ ```
46
+
47
+ ---
48
+
49
+ ## 3. Manual Try/Catch for Standard Loads
50
+
51
+ ```typescript
52
+ // BAD — unnecessary boilerplate
53
+ async load() {
54
+ this.set({ loading: true, error: null });
55
+ try {
56
+ const data = await this.service.getAll(this.disposeSignal);
57
+ this.set({ items: data, loading: false });
58
+ } catch (e) {
59
+ if (isAbortError(e)) return;
60
+ this.set({ loading: false, error: e.message });
61
+ }
62
+ }
63
+
64
+ // GOOD — async tracking handles everything
65
+ async load() {
66
+ const data = await this.service.getAll(this.disposeSignal);
67
+ this.collection.reset(data);
68
+ }
69
+ ```
70
+
71
+ ---
72
+
73
+ ## 4. Two-Step Setters
74
+
75
+ ```typescript
76
+ // BAD — setter calls refilter
77
+ setSearch(search: string) {
78
+ this.set({ search });
79
+ this.refilter(); // unnecessary — getters recompute on access
80
+ }
81
+
82
+ // GOOD — one-liner setter, getter handles derivation
83
+ setSearch(search: string) { this.set({ search }); }
84
+ ```
85
+
86
+ ---
87
+
88
+ ## 5. Multiple useLocal in One Component
89
+
90
+ ```tsx
91
+ // BAD — split into two components
92
+ function Dashboard() {
93
+ const [usersState, usersVM] = useLocal(UsersViewModel, { ... });
94
+ const [ordersState, ordersVM] = useLocal(OrdersViewModel, { ... });
95
+ return <div>...</div>;
96
+ }
97
+
98
+ // GOOD — each component owns one ViewModel
99
+ function Dashboard() {
100
+ return (
101
+ <div>
102
+ <UsersPanel />
103
+ <OrdersPanel />
104
+ </div>
105
+ );
106
+ }
107
+ ```
108
+
109
+ ---
110
+
111
+ ## 6. Collections Accessed from Components
112
+
113
+ ```tsx
114
+ // BAD — component imports infrastructure
115
+ import { singleton } from 'mvc-kit';
116
+ import { UsersCollection } from '../collections/UsersCollection';
117
+ function UsersPage() {
118
+ const collection = singleton(UsersCollection);
119
+ // ...
120
+ }
121
+
122
+ // GOOD — component only knows its ViewModel
123
+ import { useLocal } from 'mvc-kit/react';
124
+ import { UsersViewModel } from '../viewmodels/UsersViewModel';
125
+ function UsersPage() {
126
+ const [state, vm] = useLocal(UsersViewModel, { ... });
127
+ // ...
128
+ }
129
+ ```
130
+
131
+ ---
132
+
133
+ ## 7. Services Holding State
134
+
135
+ ```typescript
136
+ // BAD — service caches data
137
+ class UserService extends Service {
138
+ private cache: User[] = [];
139
+ async getAll() {
140
+ if (this.cache.length) return this.cache;
141
+ this.cache = await fetch(...).then(r => r.json());
142
+ return this.cache;
143
+ }
144
+ }
145
+
146
+ // GOOD — Collection is the cache, Service is stateless
147
+ class UserService extends Service {
148
+ async getAll(signal?: AbortSignal): Promise<User[]> {
149
+ const res = await fetch('/api/users', { signal });
150
+ if (!res.ok) throw new HttpError(res.status, res.statusText);
151
+ return res.json();
152
+ }
153
+ }
154
+ ```
155
+
156
+ ---
157
+
158
+ ## 8. Swallowing Errors Without Re-Throwing
159
+
160
+ ```typescript
161
+ // BAD — breaks async tracking
162
+ async save() {
163
+ try {
164
+ await this.service.save(this.state.draft, this.disposeSignal);
165
+ this.emit('saved', { id: this.state.draft.id });
166
+ } catch (e) {
167
+ this.emit('error', { message: e.message });
168
+ // Error swallowed! vm.async.save.error will be null
169
+ }
170
+ }
171
+
172
+ // GOOD — re-throw for async tracking
173
+ async save() {
174
+ try {
175
+ await this.service.save(this.state.draft, this.disposeSignal);
176
+ this.emit('saved', { id: this.state.draft.id });
177
+ } catch (e) {
178
+ if (!isAbortError(e)) {
179
+ this.emit('error', { message: classifyError(e).message });
180
+ }
181
+ throw e; // async tracking captures the error
182
+ }
183
+ }
184
+ ```
185
+
186
+ ---
187
+
188
+ ## 9. useEffect for Data Loading
189
+
190
+ ```tsx
191
+ // BAD — component orchestrates loading
192
+ function UsersPage() {
193
+ const [state, vm] = useLocal(UsersViewModel, { ... });
194
+ useEffect(() => { vm.load(); }, []);
195
+ return <div>...</div>;
196
+ }
197
+
198
+ // GOOD — ViewModel handles its own initialization
199
+ // (onInit() is called automatically by useLocal)
200
+ function UsersPage() {
201
+ const [state, vm] = useLocal(UsersViewModel, { ... });
202
+ return <div>...</div>;
203
+ }
204
+ ```
205
+
206
+ ---
207
+
208
+ ## 10. useState/useMemo/useCallback in Connected Components
209
+
210
+ ```tsx
211
+ // BAD — React hooks alongside ViewModel
212
+ function UsersPage() {
213
+ const [state, vm] = useLocal(UsersViewModel, { ... });
214
+ const [showModal, setShowModal] = useState(false);
215
+ const filtered = useMemo(() => state.items.filter(...), [state.items]);
216
+
217
+ // GOOD — put it all in the ViewModel
218
+ // state.showModal, vm.filtered, vm.toggleModal()
219
+ }
220
+ ```
221
+
222
+ ---
223
+
224
+ ## 11. Manual Optimistic Snapshot/Restore
225
+
226
+ ```typescript
227
+ // BAD — manual snapshot management
228
+ async toggleStatus(id: string) {
229
+ const prev = [...this.collection.items];
230
+ this.collection.update(id, { status: 'done' });
231
+ try {
232
+ await this.service.update(id, { status: 'done' }, this.disposeSignal);
233
+ } catch {
234
+ this.collection.reset(prev); // manual restore
235
+ }
236
+ }
237
+
238
+ // GOOD — use collection.optimistic()
239
+ async toggleStatus(id: string) {
240
+ const rollback = this.collection.optimistic(() => {
241
+ this.collection.update(id, { status: 'done' });
242
+ });
243
+ try {
244
+ await this.service.update(id, { status: 'done' }, this.disposeSignal);
245
+ } catch (e) {
246
+ if (!isAbortError(e)) rollback();
247
+ throw e;
248
+ }
249
+ }
250
+ ```
251
+
252
+ ---
253
+
254
+ ## 12. Separate EventBus for ViewModel Events
255
+
256
+ ```typescript
257
+ // BAD — unnecessary EventBus when ViewModel events suffice
258
+ class ItemViewModel extends ViewModel<State> {
259
+ private eventBus = new EventBus<{ saved: { id: string } }>();
260
+ get bus() { return this.eventBus; }
261
+ }
262
+
263
+ // GOOD — use the second generic parameter
264
+ class ItemViewModel extends ViewModel<State, { saved: { id: string } }> {
265
+ // emit('saved', { id }) is built-in and protected
266
+ }
267
+ ```
268
+
269
+ ---
270
+
271
+ ## 13. set() Inside a Getter
272
+
273
+ ```typescript
274
+ // BAD — creates infinite loop
275
+ get filtered(): Item[] {
276
+ const result = this.state.items.filter(...);
277
+ this.set({ filteredCount: result.length }); // INFINITE LOOP
278
+ return result;
279
+ }
280
+
281
+ // GOOD — use a separate getter for the count
282
+ get filtered(): Item[] { return this.state.items.filter(...); }
283
+ get filteredCount(): number { return this.filtered.length; }
284
+ ```
285
+
286
+ ---
287
+
288
+ ## 14. Logic/Filtering in Components
289
+
290
+ ```tsx
291
+ // BAD — component does derivation
292
+ function UsersPage() {
293
+ const [state, vm] = useLocal(UsersViewModel, { ... });
294
+ const active = state.items.filter(u => u.status === 'active');
295
+ return <UserList users={active} />;
296
+ }
297
+
298
+ // GOOD — getter on the ViewModel
299
+ function UsersPage() {
300
+ const [state, vm] = useLocal(UsersViewModel, { ... });
301
+ return <UserList users={vm.active} />;
302
+ }
303
+ ```
304
+
305
+ ---
306
+
307
+ ## 15. Missing disposeSignal on Async Calls
308
+
309
+ ```typescript
310
+ // BAD — no cancellation on unmount
311
+ async load() {
312
+ const data = await this.service.getAll();
313
+ this.set({ items: data });
314
+ }
315
+
316
+ // GOOD — cancels on unmount, AbortError auto-swallowed
317
+ async load() {
318
+ const data = await this.service.getAll(this.disposeSignal);
319
+ this.set({ items: data });
320
+ }
321
+ ```
@@ -0,0 +1,310 @@
1
+ # mvc-kit API Reference
2
+
3
+ ## Imports
4
+
5
+ ```typescript
6
+ // Core classes and utilities
7
+ import { ViewModel, Model, Collection, Controller, Service, EventBus, Channel } from 'mvc-kit';
8
+ import { singleton, hasSingleton, teardown, teardownAll } from 'mvc-kit';
9
+ import { HttpError, isAbortError, classifyError } from 'mvc-kit';
10
+ import type { Subscribable, Disposable, Initializable, Listener, Updater, ValidationErrors, TaskState, AppError, AsyncMethodKeys, ChannelStatus } from 'mvc-kit';
11
+
12
+ // React hooks
13
+ import { useLocal, useSingleton, useInstance, useModel, useField, useEvent, useEmit, useResolve, useTeardown, Provider } from 'mvc-kit/react';
14
+ import type { StateOf, ItemOf, SingletonClass, ProviderRegistry, ModelHandle, FieldHandle, ProviderProps } from 'mvc-kit/react';
15
+ ```
16
+
17
+ ---
18
+
19
+ ## ViewModel<S, E = {}>
20
+
21
+ Reactive state container with computed getters, automatic async tracking, and optional typed events.
22
+
23
+ ### Constructor
24
+ ```typescript
25
+ new MyViewModel(initialState: S)
26
+ ```
27
+
28
+ ### State
29
+ - `state: Readonly<S>` — Current frozen state. Read `state.x` for raw values.
30
+ - `set(partial: Partial<S>)` — Merge partial state. Skips if no values change.
31
+ - `set(updater: (prev: Readonly<S>) => Partial<S>)` — Functional update.
32
+ - `reset(newState?: S)` — Tear down lifecycle, re-initialize. Clears async tracking, re-runs `onInit()`.
33
+
34
+ ### Computed Getters
35
+ Define TypeScript `get` accessors on the subclass. Auto-memoized after `init()` with dependency tracking.
36
+ ```typescript
37
+ get filtered(): Item[] { return this.state.items.filter(i => i.active); }
38
+ ```
39
+
40
+ ### Async Tracking
41
+ After `init()`, every subclass method is wrapped. Async methods get automatic loading/error tracking.
42
+ - `async: Record<string, TaskState>` — `vm.async.methodName` returns `{ loading: boolean, error: string | null, errorCode: string | null }`.
43
+ - `subscribeAsync(listener: () => void): () => void` — Subscribe to async state changes.
44
+ - Unknown keys return `{ loading: false, error: null, errorCode: null }`.
45
+
46
+ ### Typed Events (optional)
47
+ Pass a second generic `E` to enable typed events.
48
+ ```typescript
49
+ class MyVM extends ViewModel<State, { saved: { id: string }; error: void }> {
50
+ protected emit(event, payload) // type-safe, protected
51
+ }
52
+ ```
53
+ - `events: EventBus<E>` — Lazy getter; zero cost if unused.
54
+ - `emit(event, payload)` — Protected. Only the ViewModel can emit.
55
+ - Event bus auto-disposes with the ViewModel.
56
+
57
+ ### Lifecycle Hooks
58
+ - `onInit(): void | Promise<void>` — Called once after `init()`. Data loading, subscriptions.
59
+ - `onSet(prev: Readonly<S>, next: Readonly<S>): void` — Called after every state change.
60
+ - `onDispose(): void` — Called during `dispose()`, after cleanup callbacks.
61
+
62
+ ### Cleanup
63
+ - `disposeSignal: AbortSignal` — Lazily created, auto-aborted on `dispose()`.
64
+ - `subscribeTo(source: Subscribable, listener): void` — Auto-unsubscribe on dispose.
65
+ - `addCleanup(fn: () => void): void` — Register teardown callback.
66
+ - `subscribe(listener: Listener<S>): () => void` — Subscribe to state changes.
67
+ - `dispose(): void` — Idempotent. `set()` and `emit()` become no-ops after dispose.
68
+
69
+ ### Static
70
+ - `static GHOST_TIMEOUT = 3000` — DEV-only. Timeout before ghost async warnings.
71
+
72
+ ---
73
+
74
+ ## Model<S>
75
+
76
+ Entity with validation and dirty tracking.
77
+
78
+ ### Constructor
79
+ ```typescript
80
+ new MyModel(initialState: S)
81
+ ```
82
+
83
+ ### State & Validation
84
+ - `state: Readonly<S>` — Current state.
85
+ - `set(partial: Partial<S>)` — Update state, re-validates.
86
+ - `errors: ValidationErrors<S>` — `Partial<Record<keyof S, string>>`.
87
+ - `valid: boolean` — True when `errors` is empty.
88
+ - `dirty: boolean` — True when state differs from committed state.
89
+ - `commit(): void` — Mark current state as baseline (resets dirty).
90
+ - `rollback(): void` — Revert to committed state.
91
+
92
+ ### Override
93
+ ```typescript
94
+ protected validate(state: S): ValidationErrors<S> {
95
+ const errors: Partial<Record<keyof S, string>> = {};
96
+ if (!state.name) errors.name = 'Required';
97
+ return errors;
98
+ }
99
+ ```
100
+
101
+ ### Lifecycle & Cleanup
102
+ Same as ViewModel: `onInit()`, `onSet()`, `onDispose()`, `disposeSignal`, `subscribeTo()`, `addCleanup()`, `subscribe()`, `dispose()`.
103
+
104
+ ---
105
+
106
+ ## Collection<T extends { id: string }>
107
+
108
+ Reactive typed array with CRUD and query methods. Items must have an `id: string` field.
109
+
110
+ ### CRUD (triggers notifications)
111
+ - `add(item: T): void`
112
+ - `update(id: string, partial: Partial<T>): void`
113
+ - `remove(id: string): void`
114
+ - `reset(items: T[]): void` — Replace all items.
115
+ - `clear(): void`
116
+
117
+ ### Query (pure, no notifications)
118
+ - `items: readonly T[]` — Same as `state`.
119
+ - `length: number`
120
+ - `get(id: string): T | undefined` — O(1) via internal index.
121
+ - `has(id: string): boolean`
122
+ - `find(predicate: (item: T) => boolean): T | undefined`
123
+ - `filter(predicate: (item: T) => boolean): T[]`
124
+ - `sorted(comparator: (a: T, b: T) => number): T[]`
125
+ - `map<U>(fn: (item: T) => U): U[]`
126
+
127
+ ### Optimistic Updates
128
+ ```typescript
129
+ const rollback = collection.optimistic(() => {
130
+ collection.update(id, { status: 'done' });
131
+ });
132
+ // On failure: rollback()
133
+ ```
134
+
135
+ ### Lifecycle & Cleanup
136
+ `subscribe()`, `dispose()`, `disposeSignal`, `addCleanup()`, `onDispose()`.
137
+
138
+ ---
139
+
140
+ ## Service
141
+
142
+ Stateless infrastructure adapter. Singleton-scoped.
143
+
144
+ ### Lifecycle & Cleanup
145
+ - `disposeSignal: AbortSignal`
146
+ - `addCleanup(fn: () => void): void`
147
+ - `onDispose(): void`
148
+ - `dispose(): void`
149
+ - `disposed: boolean`
150
+
151
+ No state, no getters, no async tracking. Just a base class with lifecycle.
152
+
153
+ ---
154
+
155
+ ## EventBus<E>
156
+
157
+ Typed pub/sub event bus.
158
+
159
+ - `on(event: K, handler: (payload: E[K]) => void): () => void` — Subscribe. Returns unsubscribe.
160
+ - `once(event: K, handler): () => void` — One-time subscription.
161
+ - `emit(event: K, payload: E[K]): void` — Emit event.
162
+ - `dispose(): void` — Remove all listeners.
163
+
164
+ ---
165
+
166
+ ## Channel<M>
167
+
168
+ Persistent connection (WebSocket/SSE) with auto-reconnect. Extends EventBus for message routing.
169
+
170
+ ### Subclass Contract
171
+ ```typescript
172
+ protected abstract connect(): void; // Open the connection
173
+ protected abstract disconnect(): void; // Close the connection
174
+ ```
175
+
176
+ ### Connection Control
177
+ - `open(): void` — Start connection.
178
+ - `close(): void` — Stop connection and auto-reconnect.
179
+ - `state: ChannelStatus` — `'disconnected' | 'connecting' | 'connected' | 'reconnecting'`
180
+ - `subscribe(listener): () => void` — Subscribe to status changes.
181
+
182
+ ### Auto-Reconnect
183
+ - `static RECONNECT_DELAY = 1000`
184
+ - `static MAX_RECONNECT_DELAY = 30000`
185
+ - `static MAX_RECONNECT_ATTEMPTS = Infinity`
186
+
187
+ ---
188
+
189
+ ## Controller
190
+
191
+ Stateless orchestrator. Component-scoped.
192
+
193
+ - `onInit(): void | Promise<void>`
194
+ - `onDispose(): void`
195
+ - `disposeSignal: AbortSignal`
196
+ - `subscribeTo(source, listener): void`
197
+ - `addCleanup(fn): void`
198
+ - `dispose(): void`
199
+
200
+ No state, no getters, no async tracking.
201
+
202
+ ---
203
+
204
+ ## Singleton Registry
205
+
206
+ ```typescript
207
+ singleton(Class, ...args) // Get or create singleton
208
+ hasSingleton(Class) // Check existence
209
+ teardown(Class) // Dispose and remove
210
+ teardownAll() // Dispose all (use in test beforeEach)
211
+ ```
212
+
213
+ ---
214
+
215
+ ## Error Utilities
216
+
217
+ - `HttpError(status: number, statusText: string)` — Throw from services.
218
+ - `isAbortError(error: unknown): boolean` — Guard for AbortError.
219
+ - `classifyError(error: unknown): AppError` — Returns `{ code, message, status? }`.
220
+ - Codes: `'unauthorized'`, `'forbidden'`, `'not_found'`, `'network'`, `'timeout'`, `'abort'`, `'server_error'`, `'unknown'`
221
+
222
+ ---
223
+
224
+ ## React Hooks
225
+
226
+ ### useLocal(Class, ...args) / useLocal(factory)
227
+ Component-scoped. Auto-init/dispose. Returns `[state, instance]` for Subscribables, `instance` for Disposable-only.
228
+
229
+ Optional `deps` array as final argument — recreates instance when deps change.
230
+ ```tsx
231
+ const [state, vm] = useLocal(MyViewModel, { items: [] });
232
+ const [state, vm] = useLocal(MyViewModel, { userId, data: null }, [userId]);
233
+ const controller = useLocal(() => new MyController(dep1, dep2));
234
+ ```
235
+
236
+ ### useSingleton(Class, ...args)
237
+ Singleton-scoped. Auto-init. Returns same as `useLocal`.
238
+ ```tsx
239
+ const [state, vm] = useSingleton(CartViewModel, { items: [] });
240
+ ```
241
+
242
+ ### useInstance(subscribable)
243
+ Subscribe to existing instance. No lifecycle management.
244
+ ```tsx
245
+ const state = useInstance(existingVM);
246
+ ```
247
+
248
+ ### useModel(factory)
249
+ Returns `ModelHandle: { state, errors, valid, dirty, model }`.
250
+ ```tsx
251
+ const { state, errors, valid, dirty, model } = useModel(() => new UserModel({ name: '' }));
252
+ ```
253
+
254
+ ### useField(model, key)
255
+ Returns `FieldHandle: { value, error, set }`. Surgical re-renders.
256
+ ```tsx
257
+ const { value, error, set } = useField(model, 'name');
258
+ ```
259
+
260
+ ### useEvent(source, event, handler)
261
+ Subscribe to EventBus or ViewModel event. Auto-unsubscribes.
262
+ ```tsx
263
+ useEvent(vm, 'saved', ({ id }) => toast.success(`Saved ${id}`));
264
+ ```
265
+
266
+ ### useEmit(bus)
267
+ Stable emit function.
268
+ ```tsx
269
+ const emit = useEmit(bus);
270
+ emit('user:login', { userId: '123' });
271
+ ```
272
+
273
+ ### Provider & useResolve
274
+ DI for testing/Storybook.
275
+ ```tsx
276
+ <Provider provide={[[ApiService, mockApi]]}>
277
+ <MyComponent />
278
+ </Provider>
279
+
280
+ const api = useResolve(ApiService); // falls back to singleton()
281
+ ```
282
+
283
+ ### useTeardown(...Classes)
284
+ Teardown singletons on unmount.
285
+ ```tsx
286
+ useTeardown(UserViewModel, CartViewModel);
287
+ ```
288
+
289
+ ---
290
+
291
+ ## Interfaces
292
+
293
+ ```typescript
294
+ interface Subscribable<S> { state: Readonly<S>; subscribe(listener: Listener<S>): () => void; dispose(): void; disposeSignal: AbortSignal; }
295
+ interface Disposable { disposed: boolean; disposeSignal: AbortSignal; dispose(): void; }
296
+ interface Initializable { initialized: boolean; init(): void; }
297
+ type Listener<S> = (state: Readonly<S>, prev: Readonly<S>) => void;
298
+ type Updater<S> = (state: Readonly<S>) => Partial<S>;
299
+ type ValidationErrors<S> = Partial<Record<keyof S, string>>;
300
+ interface TaskState { readonly loading: boolean; readonly error: string | null; readonly errorCode: string | null; }
301
+ ```
302
+
303
+ ## Dev Mode (`__MVC_KIT_DEV__`)
304
+
305
+ Enable in Vite:
306
+ ```typescript
307
+ define: { __MVC_KIT_DEV__: process.env.NODE_ENV !== 'production' }
308
+ ```
309
+
310
+ Checks: `set()` inside getter, ghost async ops, method call after dispose, method call before init, reserved key override.