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 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 ViewModelsreplace verbose AbortError checks
368
- if (isAbortError(e)) return;
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 DOMException |
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
- if (!isAbortError(e)) {
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
- if (!isAbortError(e)) {
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. Only guard with `isAbortError()`.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mvc-kit",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
4
4
  "description": "Zero-magic, class-based reactive ViewModel library",
5
5
  "type": "module",
6
6
  "main": "./dist/mvc-kit.cjs",