mvc-kit 2.2.1 → 2.2.2
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 +9 -5
- package/agent-config/claude-code/skills/guide/SKILL.md +1 -1
- package/agent-config/claude-code/skills/guide/anti-patterns.md +2 -4
- package/agent-config/claude-code/skills/guide/api-reference.md +1 -1
- package/agent-config/claude-code/skills/guide/patterns.md +4 -5
- package/agent-config/claude-code/skills/review/checklist.md +1 -1
- package/agent-config/copilot/copilot-instructions.md +4 -2
- package/agent-config/cursor/cursorrules +4 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -325,7 +325,9 @@ vm.fetchUsers(); // loading: true (count: 2)
|
|
|
325
325
|
#### Error handling
|
|
326
326
|
|
|
327
327
|
- **Normal errors** are captured in `TaskState.error` (as a string message) AND re-thrown — standard Promise rejection behavior is preserved
|
|
328
|
-
- **AbortErrors** are silently swallowed — not captured in `TaskState.error`, not re-thrown
|
|
328
|
+
- **AbortErrors** are silently swallowed by the wrapper — not captured in `TaskState.error`, not re-thrown from the outer promise
|
|
329
|
+
|
|
330
|
+
For methods without try/catch, AbortError handling is fully automatic. For methods with explicit try/catch, see [Error Utilities](#error-utilities) for when `isAbortError()` is needed.
|
|
329
331
|
|
|
330
332
|
#### Sync method pruning
|
|
331
333
|
|
|
@@ -364,14 +366,16 @@ import { isAbortError, classifyError, HttpError } from 'mvc-kit';
|
|
|
364
366
|
// In services — throw typed HTTP errors
|
|
365
367
|
if (!res.ok) throw new HttpError(res.status, res.statusText);
|
|
366
368
|
|
|
367
|
-
// In
|
|
368
|
-
if (isAbortError(e))
|
|
369
|
+
// In ViewModel catch blocks — guard shared-state side effects on abort
|
|
370
|
+
if (!isAbortError(e)) rollback();
|
|
369
371
|
|
|
370
372
|
// Classify any error into a canonical shape
|
|
371
373
|
const appError = classifyError(error);
|
|
372
374
|
// appError.code: 'unauthorized' | 'network' | 'timeout' | 'abort' | ...
|
|
373
375
|
```
|
|
374
376
|
|
|
377
|
+
**When to use `isAbortError()`:** The async tracking wrapper swallows AbortErrors at the outer promise level, but your catch blocks do receive them. Use `isAbortError()` only when the catch block has side effects on shared state (like rolling back optimistic updates on a singleton Collection). You don't need it for `set()` or `emit()` (both are no-ops after dispose), and you never need it in methods without try/catch.
|
|
378
|
+
|
|
375
379
|
## Signal & Cleanup
|
|
376
380
|
|
|
377
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.
|
|
@@ -685,7 +689,7 @@ function App() {
|
|
|
685
689
|
|--------|-------------|
|
|
686
690
|
| `AppError` (type) | Canonical error shape with typed `code` field |
|
|
687
691
|
| `HttpError` | Typed HTTP error class for services to throw |
|
|
688
|
-
| `isAbortError(error)` | Guard for AbortError
|
|
692
|
+
| `isAbortError(error)` | Guard for AbortError — use in catch blocks with shared-state side effects |
|
|
689
693
|
| `classifyError(error)` | Maps raw errors → `AppError` |
|
|
690
694
|
|
|
691
695
|
### React Hooks
|
|
@@ -722,7 +726,7 @@ function App() {
|
|
|
722
726
|
- ViewModel has built-in typed events via optional second generic `E` — `events` getter, `emit()` method
|
|
723
727
|
- After `init()`, all subclass methods are wrapped for automatic async tracking; `vm.async.methodName` returns `TaskState`
|
|
724
728
|
- Sync methods are auto-pruned on first call — zero overhead after detection
|
|
725
|
-
- Async errors are re-thrown (preserves standard Promise rejection); AbortErrors are silently swallowed
|
|
729
|
+
- Async errors are re-thrown (preserves standard Promise rejection); AbortErrors are silently swallowed by the wrapper (but internal catch blocks do receive them — use `isAbortError()` to guard shared-state side effects like Collection rollbacks)
|
|
726
730
|
- `async` and `subscribeAsync` are reserved property names on ViewModel
|
|
727
731
|
- React hooks (`useLocal`, `useModel`, `useSingleton`) auto-call `init()` after mount
|
|
728
732
|
- `singleton()` does **not** auto-call `init()` — call it manually outside React
|
|
@@ -35,7 +35,7 @@ You are assisting a developer using **mvc-kit**, a zero-dependency TypeScript-fi
|
|
|
35
35
|
|
|
36
36
|
6. **Lifecycle**: `construct → init() → use → dispose()`. React hooks auto-call `init()`. Use `onInit()` for data loading.
|
|
37
37
|
|
|
38
|
-
7. **Async tracking is automatic.** After `init()`, all async methods are tracked. `vm.async.methodName` returns `{ loading, error, errorCode }`. AbortErrors are silently swallowed. Other errors are captured AND re-thrown.
|
|
38
|
+
7. **Async tracking is automatic.** After `init()`, all async methods are tracked. `vm.async.methodName` returns `{ loading, error, errorCode }`. AbortErrors are silently swallowed by the wrapper. Other errors are captured AND re-thrown. Internal catch blocks do receive AbortErrors — use `isAbortError()` only to guard shared-state side effects (like Collection rollbacks). `set()` and `emit()` don't need the guard (no-ops after dispose).
|
|
39
39
|
|
|
40
40
|
8. **Pass `this.disposeSignal`** to every async call for automatic cancellation on unmount.
|
|
41
41
|
|
|
@@ -175,9 +175,7 @@ async save() {
|
|
|
175
175
|
await this.service.save(this.state.draft, this.disposeSignal);
|
|
176
176
|
this.emit('saved', { id: this.state.draft.id });
|
|
177
177
|
} catch (e) {
|
|
178
|
-
|
|
179
|
-
this.emit('error', { message: classifyError(e).message });
|
|
180
|
-
}
|
|
178
|
+
this.emit('error', { message: classifyError(e).message }); // no isAbortError guard needed — emit() is a no-op after dispose
|
|
181
179
|
throw e; // async tracking captures the error
|
|
182
180
|
}
|
|
183
181
|
}
|
|
@@ -243,7 +241,7 @@ async toggleStatus(id: string) {
|
|
|
243
241
|
try {
|
|
244
242
|
await this.service.update(id, { status: 'done' }, this.disposeSignal);
|
|
245
243
|
} catch (e) {
|
|
246
|
-
if (!isAbortError(e)) rollback();
|
|
244
|
+
if (!isAbortError(e)) rollback(); // guard needed — rollback affects shared Collection
|
|
247
245
|
throw e;
|
|
248
246
|
}
|
|
249
247
|
}
|
|
@@ -215,7 +215,7 @@ teardownAll() // Dispose all (use in test beforeEach)
|
|
|
215
215
|
## Error Utilities
|
|
216
216
|
|
|
217
217
|
- `HttpError(status: number, statusText: string)` — Throw from services.
|
|
218
|
-
- `isAbortError(error: unknown): boolean` — Guard for AbortError.
|
|
218
|
+
- `isAbortError(error: unknown): boolean` — Guard for AbortError. Only needed in catch blocks with shared-state side effects (e.g., Collection rollbacks). Not needed for `set()`/`emit()` (no-ops after dispose) or methods without try/catch.
|
|
219
219
|
- `classifyError(error: unknown): AppError` — Returns `{ code, message, status? }`.
|
|
220
220
|
- Codes: `'unauthorized'`, `'forbidden'`, `'not_found'`, `'network'`, `'timeout'`, `'abort'`, `'server_error'`, `'unknown'`
|
|
221
221
|
|
|
@@ -104,18 +104,17 @@ async load() {
|
|
|
104
104
|
|
|
105
105
|
No try/catch. No `set({ loading: true })`. No AbortError check. The framework handles it.
|
|
106
106
|
|
|
107
|
-
**Only add try/catch** for imperative events on error:
|
|
107
|
+
**Only add try/catch** for imperative events on error or optimistic rollbacks:
|
|
108
108
|
|
|
109
109
|
```typescript
|
|
110
|
+
// Imperative events — no isAbortError guard needed (emit is a no-op after dispose)
|
|
110
111
|
async save() {
|
|
111
112
|
try {
|
|
112
113
|
const result = await this.service.save(this.state.draft, this.disposeSignal);
|
|
113
114
|
this.collection.update(result.id, result);
|
|
114
115
|
this.emit('saved', { id: result.id });
|
|
115
116
|
} catch (e) {
|
|
116
|
-
|
|
117
|
-
this.emit('error', { message: classifyError(e).message });
|
|
118
|
-
}
|
|
117
|
+
this.emit('error', { message: classifyError(e).message });
|
|
119
118
|
throw e; // MUST re-throw so async tracking captures it
|
|
120
119
|
}
|
|
121
120
|
}
|
|
@@ -235,7 +234,7 @@ async toggleStatus(id: string) {
|
|
|
235
234
|
try {
|
|
236
235
|
await this.service.update(id, { status: 'done' }, this.disposeSignal);
|
|
237
236
|
} catch (e) {
|
|
238
|
-
if (!isAbortError(e)) rollback();
|
|
237
|
+
if (!isAbortError(e)) rollback(); // guard needed — rollback affects shared Collection
|
|
239
238
|
throw e; // re-throw for async tracking
|
|
240
239
|
}
|
|
241
240
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
2. **No derived state in State** — State must not contain values computable from other state (filtered lists, counts, flags). Use getters.
|
|
8
8
|
3. **No `set()` inside getters** — Creates infinite loop. Derived values must be pure computations.
|
|
9
9
|
4. **`disposeSignal` on async calls** — Every `fetch()`, service call, or async operation must receive `this.disposeSignal` or a composed signal.
|
|
10
|
-
5. **Re-throw in try/catch** — Any explicit try/catch must re-throw the error so async tracking captures it.
|
|
10
|
+
5. **Re-throw in try/catch** — Any explicit try/catch must re-throw the error so async tracking captures it. Use `isAbortError()` only to guard shared-state side effects (Collection rollbacks). Not needed for `set()`/`emit()` (no-ops after dispose).
|
|
11
11
|
|
|
12
12
|
### Warning
|
|
13
13
|
6. **Section order** — Must follow: Private fields → Computed getters → Lifecycle → Actions → Setters.
|
|
@@ -181,7 +181,7 @@ async toggleStatus(id: string) {
|
|
|
181
181
|
try {
|
|
182
182
|
await this.service.update(id, { status: 'done' }, this.disposeSignal);
|
|
183
183
|
} catch (e) {
|
|
184
|
-
if (!isAbortError(e)) rollback();
|
|
184
|
+
if (!isAbortError(e)) rollback(); // guard needed — rollback affects shared Collection
|
|
185
185
|
throw e;
|
|
186
186
|
}
|
|
187
187
|
}
|
|
@@ -190,9 +190,11 @@ async toggleStatus(id: string) {
|
|
|
190
190
|
## Error Handling Layers
|
|
191
191
|
|
|
192
192
|
1. **Async tracking** (automatic): Write happy path, read `vm.async.method.error`
|
|
193
|
-
2. **Imperative events** (explicit): try/catch + `emit()` + **re-throw
|
|
193
|
+
2. **Imperative events** (explicit): try/catch + `emit()` + **re-throw**. No `isAbortError()` guard needed — `emit()` and `set()` are no-ops after dispose.
|
|
194
194
|
3. **Error classification** (services): `throw HttpError`, `classifyError()`
|
|
195
195
|
|
|
196
|
+
**`isAbortError()` rule:** Only needed in catch blocks that affect shared state (Collection rollbacks). Not needed for `set()`/`emit()` or methods without try/catch.
|
|
197
|
+
|
|
196
198
|
## Testing
|
|
197
199
|
|
|
198
200
|
```typescript
|
|
@@ -181,7 +181,7 @@ async toggleStatus(id: string) {
|
|
|
181
181
|
try {
|
|
182
182
|
await this.service.update(id, { status: 'done' }, this.disposeSignal);
|
|
183
183
|
} catch (e) {
|
|
184
|
-
if (!isAbortError(e)) rollback();
|
|
184
|
+
if (!isAbortError(e)) rollback(); // guard needed — rollback affects shared Collection
|
|
185
185
|
throw e;
|
|
186
186
|
}
|
|
187
187
|
}
|
|
@@ -190,9 +190,11 @@ async toggleStatus(id: string) {
|
|
|
190
190
|
## Error Handling Layers
|
|
191
191
|
|
|
192
192
|
1. **Async tracking** (automatic): Write happy path, read `vm.async.method.error`
|
|
193
|
-
2. **Imperative events** (explicit): try/catch + `emit()` + **re-throw
|
|
193
|
+
2. **Imperative events** (explicit): try/catch + `emit()` + **re-throw**. No `isAbortError()` guard needed — `emit()` and `set()` are no-ops after dispose.
|
|
194
194
|
3. **Error classification** (services): `throw HttpError`, `classifyError()`
|
|
195
195
|
|
|
196
|
+
**`isAbortError()` rule:** Only needed in catch blocks that affect shared state (Collection rollbacks). Not needed for `set()`/`emit()` or methods without try/catch.
|
|
197
|
+
|
|
196
198
|
## Testing
|
|
197
199
|
|
|
198
200
|
```typescript
|