mvc-kit 2.13.1 → 2.14.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/BEST_PRACTICES.md +53 -4
- package/agent-config/claude-code/agents/mvc-kit-architect.md +2 -2
- package/agent-config/claude-code/skills/{guide → mvc-kit}/SKILL.md +2 -2
- package/agent-config/claude-code/skills/{guide → mvc-kit}/anti-patterns.md +47 -0
- package/agent-config/claude-code/skills/{guide → mvc-kit}/patterns.md +11 -0
- package/agent-config/claude-code/skills/{guide → mvc-kit}/recipes.md +103 -22
- package/agent-config/claude-code/skills/{guide → mvc-kit}/testing.md +3 -2
- package/agent-config/claude-code/skills/{review → mvc-kit-review}/checklist.md +7 -6
- package/agent-config/copilot/copilot-instructions.md +34 -0
- package/agent-config/cursor/cursorrules +34 -0
- package/agent-config/lib/install-claude.mjs +39 -116
- package/dist/react/components/DataTable.cjs +5 -9
- package/dist/react/components/DataTable.cjs.map +1 -1
- package/dist/react/components/DataTable.d.ts.map +1 -1
- package/dist/react/components/DataTable.js +5 -9
- package/dist/react/components/DataTable.js.map +1 -1
- package/dist/react/use-instance.cjs +6 -3
- package/dist/react/use-instance.cjs.map +1 -1
- package/dist/react/use-instance.d.ts.map +1 -1
- package/dist/react/use-instance.js +6 -3
- package/dist/react/use-instance.js.map +1 -1
- package/dist/react/use-local.cjs +1 -0
- package/dist/react/use-local.cjs.map +1 -1
- package/dist/react/use-local.js +1 -0
- package/dist/react/use-local.js.map +1 -1
- package/dist/react/use-model.cjs +34 -8
- package/dist/react/use-model.cjs.map +1 -1
- package/dist/react/use-model.d.ts.map +1 -1
- package/dist/react/use-model.js +34 -8
- package/dist/react/use-model.js.map +1 -1
- package/dist/react/use-subscribe-only.cjs +3 -2
- package/dist/react/use-subscribe-only.cjs.map +1 -1
- package/dist/react/use-subscribe-only.d.ts.map +1 -1
- package/dist/react/use-subscribe-only.js +3 -2
- package/dist/react/use-subscribe-only.js.map +1 -1
- package/examples/react/AuthExample/src/components/AdminPage.tsx +3 -1
- package/examples/react/AuthExample/src/components/DashboardPage.tsx +4 -2
- package/examples/react/AuthExample/src/components/ProfilePage.tsx +2 -1
- package/package.json +1 -1
- package/src/Model.md +55 -6
- package/src/Service.md +4 -1
- package/src/react/components/DataTable.tsx +9 -13
- package/src/react/use-instance.ts +14 -3
- package/src/react/use-local.ts +2 -1
- package/src/react/use-model.md +51 -4
- package/src/react/use-model.test.tsx +86 -0
- package/src/react/use-model.ts +44 -15
- package/src/react/use-subscribe-only.ts +3 -2
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/api-reference.md +0 -0
- /package/agent-config/claude-code/skills/{review → mvc-kit-review}/SKILL.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/SKILL.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/channel.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/collection.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/controller.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/eventbus.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/model.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/page-component.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/persistent-collection.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/resource.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/service.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/viewmodel.md +0 -0
package/BEST_PRACTICES.md
CHANGED
|
@@ -986,11 +986,59 @@ class UserFormModel extends Model<UserFormState> {
|
|
|
986
986
|
|
|
987
987
|
### Models Inside ViewModels
|
|
988
988
|
|
|
989
|
-
For forms with surrounding page state (submission, server errors), wrap the Model inside a ViewModel:
|
|
989
|
+
For forms with surrounding page state (submission, server errors), wrap the Model inside a ViewModel. There are two shapes; the right choice depends on whether the form's initial data is already in hand:
|
|
990
|
+
|
|
991
|
+
| If the initial data is... | Shape |
|
|
992
|
+
|---|---|
|
|
993
|
+
| **Available at construction** (create modal, edit modal receiving an entity prop) | Create the Model in the **constructor** as `readonly`. Non-nullable, no guard. |
|
|
994
|
+
| **Fetched by ID** after mount (edit page that loads from the server) | Declare the Model as `\| null = null` and create it in `onInit()`. Component must guard. |
|
|
995
|
+
|
|
996
|
+
**Never** write `public model!: FormModel` — the `!` definite-assignment assertion lies to TypeScript about runtime state. First render runs before `onInit()`, `vm.model` is undefined, and any component reading it crashes. Pick one of the two shapes below instead.
|
|
997
|
+
|
|
998
|
+
#### Shape 1 — Data available at construction (preferred when applicable)
|
|
990
999
|
|
|
991
1000
|
```typescript
|
|
992
1001
|
class EditUserViewModel extends ViewModel<EditState, EditEvents> {
|
|
993
|
-
|
|
1002
|
+
// `readonly` refers to the *reference* — the model's state still changes via set().
|
|
1003
|
+
public readonly model: UserFormModel;
|
|
1004
|
+
private service = singleton(UserService);
|
|
1005
|
+
|
|
1006
|
+
constructor(initialState: EditState) {
|
|
1007
|
+
super(initialState);
|
|
1008
|
+
this.model = new UserFormModel(initialState.existing ?? INITIAL_FORM_STATE);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
async save() {
|
|
1012
|
+
if (!this.model.valid) return;
|
|
1013
|
+
const result = await this.service.save(this.model.state, this.disposeSignal);
|
|
1014
|
+
this.model.commit();
|
|
1015
|
+
this.emit('saved', { id: result.id });
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
protected onDispose() {
|
|
1019
|
+
this.model.dispose();
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
The component — no guard needed; the model exists from the first render:
|
|
1025
|
+
|
|
1026
|
+
```tsx
|
|
1027
|
+
function EditUserModal({ existing, onClose }: Props) {
|
|
1028
|
+
const [, vm] = useLocal(EditUserViewModel, { existing });
|
|
1029
|
+
const saveState = vm.async.save;
|
|
1030
|
+
|
|
1031
|
+
useEvent(vm, 'saved', onClose);
|
|
1032
|
+
|
|
1033
|
+
return <EditUserForm model={vm.model} onSave={vm.save} saving={saveState.loading} />;
|
|
1034
|
+
}
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
#### Shape 2 — Data fetched by ID
|
|
1038
|
+
|
|
1039
|
+
```typescript
|
|
1040
|
+
class EditUserViewModel extends ViewModel<EditState, EditEvents> {
|
|
1041
|
+
public model: UserFormModel | null = null;
|
|
994
1042
|
private service = singleton(UserService);
|
|
995
1043
|
|
|
996
1044
|
protected async onInit() {
|
|
@@ -1000,7 +1048,7 @@ class EditUserViewModel extends ViewModel<EditState, EditEvents> {
|
|
|
1000
1048
|
}
|
|
1001
1049
|
|
|
1002
1050
|
async save() {
|
|
1003
|
-
if (!this.model.valid) return;
|
|
1051
|
+
if (!this.model || !this.model.valid) return;
|
|
1004
1052
|
await this.service.update(this.userId, this.model.state, this.disposeSignal);
|
|
1005
1053
|
this.model.commit();
|
|
1006
1054
|
this.emit('saved', { id: this.userId });
|
|
@@ -1012,7 +1060,7 @@ class EditUserViewModel extends ViewModel<EditState, EditEvents> {
|
|
|
1012
1060
|
}
|
|
1013
1061
|
```
|
|
1014
1062
|
|
|
1015
|
-
The component:
|
|
1063
|
+
The component — **must** guard on `vm.model` before rendering the form:
|
|
1016
1064
|
|
|
1017
1065
|
```tsx
|
|
1018
1066
|
function EditUserPage() {
|
|
@@ -1388,3 +1436,4 @@ Components emit data attributes for CSS hooks — no class name opinions:
|
|
|
1388
1436
|
- `Pending` as a ViewModel own property — it dies on unmount; put it on the singleton Resource
|
|
1389
1437
|
- Passing `this.disposeSignal` to fetch inside Pending's execute callback — would abort on VM unmount, defeating resilience
|
|
1390
1438
|
- Using `collection.optimistic()` rollback with Pending — snapshot goes stale during retries
|
|
1439
|
+
- Write `public model!: FormModel` or any other `!` definite-assignment / non-null assertion on a ViewModel / Model field — the `!` lies to TypeScript about runtime state. Either make the field non-nullable by creating it in the constructor, or declare it nullable and guard the component render. Same rule for non-null assertions on any nullable value (`foo!.bar`) — narrow or restructure instead.
|
|
@@ -6,14 +6,14 @@ tools: Read, Grep, Glob, Bash
|
|
|
6
6
|
memory: project
|
|
7
7
|
color: blue
|
|
8
8
|
skills:
|
|
9
|
-
- mvc-kit
|
|
9
|
+
- mvc-kit
|
|
10
10
|
---
|
|
11
11
|
|
|
12
12
|
You are an architecture planning agent for applications built with **mvc-kit**, a TypeScript-first reactive state management library for React. You help developers plan features by deciding which classes to create, designing state shapes, and selecting sharing patterns.
|
|
13
13
|
|
|
14
14
|
## Documentation
|
|
15
15
|
|
|
16
|
-
The mvc-kit framework reference skill is preloaded into this agent's context. For deeper reference, read the supporting files in the mvc-kit skill directory (search for `.claude/skills/mvc-kit/` or `node_modules/mvc-kit/agent-config/claude-code/skills/
|
|
16
|
+
The mvc-kit framework reference skill is preloaded into this agent's context. For deeper reference, read the supporting files in the mvc-kit skill directory (search for `.claude/skills/mvc-kit/` or `node_modules/mvc-kit/agent-config/claude-code/skills/mvc-kit/`):
|
|
17
17
|
|
|
18
18
|
- `api-reference.md` — Full API reference for all classes and hooks
|
|
19
19
|
- `patterns.md` — Prescribed patterns with code examples
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: mvc-kit
|
|
2
|
+
name: mvc-kit
|
|
3
3
|
description: "mvc-kit framework reference — class roles, architecture rules, React hooks, and decision framework. Use when working with mvc-kit imports or planning mvc-kit features."
|
|
4
4
|
user-invocable: false
|
|
5
5
|
---
|
|
@@ -84,7 +84,7 @@ Most features combine multiple classes. These are the key composition patterns:
|
|
|
84
84
|
|
|
85
85
|
- **Data table**: ViewModel + Collection/Resource + Sorting + Pagination + Selection → getter pipeline (`items → filtered → sorted → paged`). Always reset pagination when filters change.
|
|
86
86
|
- **Infinite scroll / chat**: ViewModel + Feed + Service → cursor-based server pagination with `appendPage()`. Use `AbortSignal.any()` for per-call cancellation on rapid user interactions.
|
|
87
|
-
- **Form with validation**: ViewModel
|
|
87
|
+
- **Form with validation**: ViewModel owns a Model. If the initial data is known at construction (create modal, edit modal with entity prop), declare the Model `readonly` and create it in the **constructor**. If the data is fetched by ID, declare it `| null = null` and create it in `onInit()` — the component must then guard with `if (!vm.model) return <Spinner />`. Never write `public model!: FormModel` (the `!` lies and crashes the first render). Dispose in `onDispose()`, guard `save()` on `model.valid`, commit after successful save. See Recipe 3 (known data) and Recipe 4 (fetched data).
|
|
88
88
|
- **Two-tier events**: ViewModel `.emit()` for component reactions (navigate, show inline error). EventBus `.emit()` for app-wide side effects (toasts, analytics).
|
|
89
89
|
- **Singleton with DEFAULT_STATE**: `static DEFAULT_STATE` lets `singleton()` and `useSingleton()` work without repeating initial state at every call site.
|
|
90
90
|
|
|
@@ -591,3 +591,50 @@ async deleteItem(id: string) {
|
|
|
591
591
|
});
|
|
592
592
|
}
|
|
593
593
|
```
|
|
594
|
+
|
|
595
|
+
---
|
|
596
|
+
|
|
597
|
+
## 26. Definite-Assignment Assertion (`!`) on a Model/ViewModel Field
|
|
598
|
+
|
|
599
|
+
```typescript
|
|
600
|
+
// BAD — `!` lies to TypeScript about runtime state.
|
|
601
|
+
// First render runs before onInit(), so vm.model is undefined and useField/useModel crash.
|
|
602
|
+
class EditVM extends ViewModel<EditState> {
|
|
603
|
+
public model!: FormModel;
|
|
604
|
+
|
|
605
|
+
protected onInit() {
|
|
606
|
+
this.model = new FormModel(this.state.existing);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// GOOD (data known at construction) — create in constructor, non-nullable
|
|
611
|
+
class EditVM extends ViewModel<EditState> {
|
|
612
|
+
public readonly model: FormModel;
|
|
613
|
+
|
|
614
|
+
constructor(state: EditState) {
|
|
615
|
+
super(state);
|
|
616
|
+
this.model = new FormModel(state.existing ?? INITIAL);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
protected onDispose() { this.model.dispose(); }
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// GOOD (data fetched by ID) — declare nullable, guard in component
|
|
623
|
+
class EditVM extends ViewModel<EditState> {
|
|
624
|
+
public model: FormModel | null = null;
|
|
625
|
+
|
|
626
|
+
protected async onInit() {
|
|
627
|
+
const entity = await this.service.getById(this.state.id, this.disposeSignal);
|
|
628
|
+
this.model = new FormModel(entity);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
protected onDispose() { this.model?.dispose(); }
|
|
632
|
+
}
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
```tsx
|
|
636
|
+
// Component must guard for the nullable shape:
|
|
637
|
+
if (!vm.model) return <Spinner />;
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
Extends to non-null assertions in general (`foo!.bar`, `arr[0]!`) — don't use `!` to paper over a nullable type when the real fix is either (a) make it non-nullable by construction, or (b) narrow it with a proper check. See Recipe 3 (known data) and Recipe 4 (fetched data).
|
|
@@ -608,6 +608,17 @@ class UserFormModel extends Model<UserFormState> {
|
|
|
608
608
|
const { state, errors, valid, dirty, model } = useModel(() => new UserFormModel({ name: '', email: '' }));
|
|
609
609
|
```
|
|
610
610
|
|
|
611
|
+
### Model inside a ViewModel — two shapes
|
|
612
|
+
|
|
613
|
+
| If the initial data is... | Shape |
|
|
614
|
+
|---|---|
|
|
615
|
+
| **Available at construction** (create modal, edit modal with entity prop) | `readonly model: FormModel;` + assign in constructor. Non-nullable, no guard. |
|
|
616
|
+
| **Fetched by ID** after mount | `model: FormModel \| null = null;` + assign in `onInit()`. Component must guard with `if (!vm.model) return <Spinner />`. |
|
|
617
|
+
|
|
618
|
+
**Never** write `public model!: FormModel`. The `!` definite-assignment assertion lies about runtime state: first render runs before `onInit`, so `vm.model` is `undefined`, and `useField`/`useModel` crash. Always either make the field non-nullable via constructor creation, or declare it nullable and guard.
|
|
619
|
+
|
|
620
|
+
See Recipe 3 (known data) and Recipe 4 (fetched data) in [recipes.md](recipes.md).
|
|
621
|
+
|
|
611
622
|
---
|
|
612
623
|
|
|
613
624
|
## Testing Pattern
|
|
@@ -240,9 +240,96 @@ function MessageThread({ conversationId }: { conversationId: string }) {
|
|
|
240
240
|
|
|
241
241
|
---
|
|
242
242
|
|
|
243
|
-
##
|
|
243
|
+
## Choosing a Form Recipe
|
|
244
244
|
|
|
245
|
-
|
|
245
|
+
| If the form's initial data is... | Use |
|
|
246
|
+
|---|---|
|
|
247
|
+
| **Already available** at construction (create modal, edit modal receiving an entity prop, wizard step) | **Recipe 3** — Form with Known Initial Data |
|
|
248
|
+
| **Fetched by ID** after mount (edit page that loads from the server) | **Recipe 4** — Form that Loads by ID |
|
|
249
|
+
|
|
250
|
+
The distinction matters because Recipe 4 has a null guard and a lifecycle trap that Recipe 3 does not. Don't copy Recipe 4 when Recipe 3 applies — you'll write defensive code for a nullability you never needed.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## Recipe 3: Form with Known Initial Data
|
|
255
|
+
|
|
256
|
+
The initial data is available when the ViewModel is constructed — passed through `useLocal`'s initial state, or known from a constant. The Model is created in the constructor, so it's non-nullable from the first render onward.
|
|
257
|
+
|
|
258
|
+
```typescript
|
|
259
|
+
interface EditState {
|
|
260
|
+
existing: AlertConfig | null; // null → create mode, non-null → edit mode
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
interface EditEvents {
|
|
264
|
+
saved: { id: string };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
class EditAlertConfigViewModel extends ViewModel<EditState, EditEvents> {
|
|
268
|
+
// `readonly` means the reference never changes — the model's *state* still changes via set().
|
|
269
|
+
public readonly model: AlertConfigFormModel;
|
|
270
|
+
|
|
271
|
+
private service = singleton(AlertConfigService);
|
|
272
|
+
private collection = singleton(AlertConfigsCollection);
|
|
273
|
+
|
|
274
|
+
constructor(initialState: EditState) {
|
|
275
|
+
super(initialState);
|
|
276
|
+
this.model = new AlertConfigFormModel(
|
|
277
|
+
initialState.existing
|
|
278
|
+
? alertConfigToFormState(initialState.existing)
|
|
279
|
+
: INITIAL_FORM_STATE,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
get canSave(): boolean {
|
|
284
|
+
return this.model.valid && this.model.dirty;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
protected onDispose() {
|
|
288
|
+
this.model.dispose();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async save() {
|
|
292
|
+
if (!this.model.valid) return;
|
|
293
|
+
const result = this.state.existing
|
|
294
|
+
? await this.service.update(this.state.existing.id, this.model.state, this.disposeSignal)
|
|
295
|
+
: await this.service.create(this.model.state, this.disposeSignal);
|
|
296
|
+
this.collection.upsert(result);
|
|
297
|
+
this.model.commit();
|
|
298
|
+
this.emit('saved', { id: result.id });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
```tsx
|
|
304
|
+
function EditAlertConfigModal({ existing, onClose }: { existing: AlertConfig | null; onClose: () => void }) {
|
|
305
|
+
const [, vm] = useLocal(EditAlertConfigViewModel, { existing });
|
|
306
|
+
const { loading: saving } = vm.async.save;
|
|
307
|
+
|
|
308
|
+
useEvent(vm, 'saved', onClose);
|
|
309
|
+
|
|
310
|
+
// No `!vm.model` guard — the model exists from the first render.
|
|
311
|
+
return (
|
|
312
|
+
<form onSubmit={e => { e.preventDefault(); vm.save(); }}>
|
|
313
|
+
<ModelFields model={vm.model} />
|
|
314
|
+
<button disabled={!vm.canSave || saving}>
|
|
315
|
+
{saving ? 'Saving…' : 'Save'}
|
|
316
|
+
</button>
|
|
317
|
+
</form>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
**Key rules:**
|
|
323
|
+
- Declare the field as `readonly model: FormModel` and assign it in the constructor — not `model!:` (the `!` would lie about runtime state)
|
|
324
|
+
- Pair the constructor creation with `this.model.dispose()` in `onDispose()`
|
|
325
|
+
- No null guard needed — the component never sees a half-initialized ViewModel
|
|
326
|
+
- Re-create the ViewModel when the entity changes using `useLocal(..., [existing.id])` — the constructor re-runs with the new data
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
## Recipe 4: Form that Loads by ID
|
|
331
|
+
|
|
332
|
+
The ViewModel only knows an ID at construction; the entity is fetched in `onInit()`. The Model is nullable until the fetch completes, and the component must guard its render.
|
|
246
333
|
|
|
247
334
|
```typescript
|
|
248
335
|
interface ProfileState {
|
|
@@ -255,32 +342,25 @@ interface ProfileEvents {
|
|
|
255
342
|
}
|
|
256
343
|
|
|
257
344
|
class LocationProfileViewModel extends ViewModel<ProfileState, ProfileEvents> {
|
|
258
|
-
// Model is created
|
|
345
|
+
// Model is created after the fetch resolves — starts null, component must guard.
|
|
259
346
|
public model: LocationFormModel | null = null;
|
|
260
347
|
|
|
261
348
|
private service = singleton(LocationService);
|
|
262
349
|
private collection = singleton(LocationsCollection);
|
|
263
350
|
private bus = singleton(AppEventBus);
|
|
264
351
|
|
|
265
|
-
// --- Computed getters ---
|
|
266
|
-
|
|
267
352
|
get canSave(): boolean {
|
|
268
353
|
return this.model !== null && this.model.valid && this.model.dirty;
|
|
269
354
|
}
|
|
270
355
|
|
|
271
|
-
// --- Lifecycle ---
|
|
272
|
-
|
|
273
356
|
protected onInit() {
|
|
274
357
|
this.load();
|
|
275
358
|
}
|
|
276
359
|
|
|
277
360
|
protected onDispose() {
|
|
278
|
-
// Model has its own lifecycle — must dispose
|
|
279
361
|
this.model?.dispose();
|
|
280
362
|
}
|
|
281
363
|
|
|
282
|
-
// --- Actions ---
|
|
283
|
-
|
|
284
364
|
async load() {
|
|
285
365
|
const location = await this.service.getById(this.state.locationId, this.disposeSignal);
|
|
286
366
|
|
|
@@ -303,7 +383,7 @@ class LocationProfileViewModel extends ViewModel<ProfileState, ProfileEvents> {
|
|
|
303
383
|
this.disposeSignal,
|
|
304
384
|
);
|
|
305
385
|
this.collection.update(this.state.locationId, updated);
|
|
306
|
-
this.model.commit();
|
|
386
|
+
this.model.commit();
|
|
307
387
|
this.set({ location: updated });
|
|
308
388
|
this.emit('saved', { id: updated.id });
|
|
309
389
|
this.bus.emit('toast:show', { message: 'Location saved', severity: 'success' });
|
|
@@ -311,7 +391,7 @@ class LocationProfileViewModel extends ViewModel<ProfileState, ProfileEvents> {
|
|
|
311
391
|
if (!isAbortError(e)) {
|
|
312
392
|
this.bus.emit('toast:show', { message: 'Failed to save', severity: 'error' });
|
|
313
393
|
}
|
|
314
|
-
throw e;
|
|
394
|
+
throw e;
|
|
315
395
|
}
|
|
316
396
|
}
|
|
317
397
|
}
|
|
@@ -319,18 +399,19 @@ class LocationProfileViewModel extends ViewModel<ProfileState, ProfileEvents> {
|
|
|
319
399
|
|
|
320
400
|
```tsx
|
|
321
401
|
function LocationProfile({ locationId }: { locationId: string }) {
|
|
322
|
-
const [
|
|
323
|
-
const { loading: saving } = vm.async.save
|
|
402
|
+
const [, vm] = useLocal(LocationProfileViewModel, { location: null, locationId });
|
|
403
|
+
const { loading: saving } = vm.async.save;
|
|
324
404
|
|
|
325
405
|
useEvent(vm, 'saved', () => navigate('/locations'));
|
|
326
406
|
|
|
407
|
+
// Required: the model is created in onInit() after the fetch resolves.
|
|
327
408
|
if (!vm.model) return <Spinner />;
|
|
328
409
|
|
|
329
410
|
return (
|
|
330
411
|
<form onSubmit={e => { e.preventDefault(); vm.save(); }}>
|
|
331
412
|
<ModelFields model={vm.model} />
|
|
332
413
|
<button disabled={!vm.canSave || saving}>
|
|
333
|
-
{saving ? 'Saving
|
|
414
|
+
{saving ? 'Saving…' : 'Save'}
|
|
334
415
|
</button>
|
|
335
416
|
</form>
|
|
336
417
|
);
|
|
@@ -338,16 +419,16 @@ function LocationProfile({ locationId }: { locationId: string }) {
|
|
|
338
419
|
```
|
|
339
420
|
|
|
340
421
|
**Key rules:**
|
|
341
|
-
-
|
|
342
|
-
- Guard
|
|
343
|
-
-
|
|
344
|
-
-
|
|
422
|
+
- Declare the field as `model: FormModel | null = null` — not `model!:` (the `!` would lie about runtime state and crash `useField`/`useModel` on the first render)
|
|
423
|
+
- Guard the form render with `if (!vm.model) return <Spinner />` — the model is undefined until `onInit` resolves
|
|
424
|
+
- Dispose with `this.model?.dispose()` (optional chain — may never have been created on error paths)
|
|
425
|
+
- Guard `save()` with `!this.model || !this.model.valid`
|
|
345
426
|
- For large forms: use `useModelRef(factory)` + `useField(model, 'fieldName')` for surgical per-field re-renders
|
|
346
427
|
- ViewModel events (`emit('saved')`) for navigation; EventBus for app-wide toasts
|
|
347
428
|
|
|
348
429
|
---
|
|
349
430
|
|
|
350
|
-
## Recipe
|
|
431
|
+
## Recipe 5: Two-Tier Event System
|
|
351
432
|
|
|
352
433
|
ViewModel events are for "this instance did something" (component reactions). EventBus events are for "something happened app-wide" (toasts, analytics, navigation).
|
|
353
434
|
|
|
@@ -419,7 +500,7 @@ function ToastProvider() {
|
|
|
419
500
|
|
|
420
501
|
---
|
|
421
502
|
|
|
422
|
-
## Recipe
|
|
503
|
+
## Recipe 6: Resource with Singleton DEFAULT_STATE
|
|
423
504
|
|
|
424
505
|
Singleton ViewModels that share state across routes need `static DEFAULT_STATE` so `singleton()` and `useSingleton()` work without repeating initial state at every call site.
|
|
425
506
|
|
|
@@ -454,7 +535,7 @@ const [state, auth] = useSingleton(AuthViewModel); // React (auto-init + subscr
|
|
|
454
535
|
|
|
455
536
|
---
|
|
456
537
|
|
|
457
|
-
## Recipe
|
|
538
|
+
## Recipe 7: Error Handling with isAbortError
|
|
458
539
|
|
|
459
540
|
Async tracking handles errors automatically. Manual try/catch is only needed when you have **side effects** in the catch block (toasts, event emissions, collection rollbacks).
|
|
460
541
|
|
|
@@ -81,8 +81,9 @@ it('captures errors in async tracking', async () => {
|
|
|
81
81
|
|
|
82
82
|
await vm.load().catch(() => {}); // Swallow the re-thrown error
|
|
83
83
|
|
|
84
|
-
|
|
85
|
-
expect(vm.async.load.error
|
|
84
|
+
// async.error is a string (message), not an Error. errorCode is the canonical code.
|
|
85
|
+
expect(vm.async.load.error).toBe('Network error');
|
|
86
|
+
expect(vm.async.load.errorCode).toBe('unknown');
|
|
86
87
|
|
|
87
88
|
vm.dispose();
|
|
88
89
|
});
|
|
@@ -166,16 +166,17 @@
|
|
|
166
166
|
|
|
167
167
|
---
|
|
168
168
|
|
|
169
|
-
## Composition Checks (
|
|
169
|
+
## Composition Checks (7)
|
|
170
170
|
|
|
171
171
|
### Critical
|
|
172
172
|
1. **Model lifecycle pairing** — If a ViewModel creates a Model in `onInit()` or an action, it must dispose it in `onDispose()`.
|
|
173
|
+
2. **No `!` definite-assignment or non-null assertion on a ViewModel/Model field** — `public model!: FormModel` lies to TypeScript about runtime state: first render runs before `onInit`, so `vm.model` is `undefined` and any component reading it crashes. Either (a) create the Model in the constructor so it's non-nullable (`readonly model: FormModel`, assigned in `constructor`), or (b) declare it nullable (`model: FormModel \| null = null`) and guard the component render with `if (!vm.model) return <Spinner />`. Same rule applies to non-null assertions on any other nullable field. See Recipe 3 (known data) and Recipe 4 (fetched data).
|
|
173
174
|
|
|
174
175
|
### Warning
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
3. **Per-call cancellation for rapid interactions** — When users can switch contexts rapidly (conversations, search-as-you-type), use `AbortSignal.any([this.disposeSignal, controller.signal])` instead of `disposeSignal` alone.
|
|
177
|
+
4. **Two-tier events** — ViewModel `.emit()` for component-scoped reactions (navigate, show error). EventBus `.emit()` for app-wide side effects (toasts, analytics). Don't use EventBus for single-component concerns.
|
|
178
|
+
5. **Singleton DEFAULT_STATE** — Singleton ViewModels used with `useSingleton()` should have `static DEFAULT_STATE` to avoid repeating initial state at every call site.
|
|
179
|
+
6. **Smart init** — `onInit()` should check `if (this.collection.length === 0)` before fetching to prevent re-fetching when component re-mounts and data is already loaded.
|
|
179
180
|
|
|
180
181
|
### Suggestion
|
|
181
|
-
|
|
182
|
+
7. **File naming convention** — Files should follow `{Name}{Role}.ts` pattern: `UsersViewModel.ts`, `UserService.ts`, `UsersCollection.ts`, `UsersResource.ts`, `LocationFormModel.ts`, `AppEventBus.ts`.
|
|
@@ -179,6 +179,40 @@ class UserModel extends Model<UserFormState> {
|
|
|
179
179
|
}
|
|
180
180
|
```
|
|
181
181
|
|
|
182
|
+
### Model owned by a ViewModel — pick one of two shapes
|
|
183
|
+
|
|
184
|
+
If the initial data is available at construction (create modal, edit modal receiving an entity prop):
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
class EditUserVM extends ViewModel<{ existing: User | null }> {
|
|
188
|
+
public readonly model: UserModel; // non-nullable — set in constructor
|
|
189
|
+
|
|
190
|
+
constructor(state: { existing: User | null }) {
|
|
191
|
+
super(state);
|
|
192
|
+
this.model = new UserModel(state.existing ?? INITIAL);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
protected onDispose() { this.model.dispose(); }
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
If the initial data is fetched by ID:
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
class EditUserVM extends ViewModel<{ userId: string }> {
|
|
203
|
+
public model: UserModel | null = null; // nullable — component must guard
|
|
204
|
+
|
|
205
|
+
protected async onInit() {
|
|
206
|
+
const user = await this.service.getById(this.state.userId, this.disposeSignal);
|
|
207
|
+
this.model = new UserModel(user);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
protected onDispose() { this.model?.dispose(); }
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
**Never** write `public model!: UserModel` — the `!` lies to TypeScript about runtime state and causes `useField`/`useModel` to crash on the first render with "Cannot read properties of undefined." Either make the Model non-nullable via constructor creation, or declare it nullable and guard the component with `if (!vm.model) return <Spinner />`.
|
|
215
|
+
|
|
182
216
|
## Composable Helpers Pattern
|
|
183
217
|
|
|
184
218
|
Declare helpers as ViewModel instance properties. They have `subscribe()` so they're auto-tracked -- getters that read them recompute when helper state changes.
|
|
@@ -179,6 +179,40 @@ class UserModel extends Model<UserFormState> {
|
|
|
179
179
|
}
|
|
180
180
|
```
|
|
181
181
|
|
|
182
|
+
### Model owned by a ViewModel — pick one of two shapes
|
|
183
|
+
|
|
184
|
+
If the initial data is available at construction (create modal, edit modal receiving an entity prop):
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
class EditUserVM extends ViewModel<{ existing: User | null }> {
|
|
188
|
+
public readonly model: UserModel; // non-nullable — set in constructor
|
|
189
|
+
|
|
190
|
+
constructor(state: { existing: User | null }) {
|
|
191
|
+
super(state);
|
|
192
|
+
this.model = new UserModel(state.existing ?? INITIAL);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
protected onDispose() { this.model.dispose(); }
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
If the initial data is fetched by ID:
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
class EditUserVM extends ViewModel<{ userId: string }> {
|
|
203
|
+
public model: UserModel | null = null; // nullable — component must guard
|
|
204
|
+
|
|
205
|
+
protected async onInit() {
|
|
206
|
+
const user = await this.service.getById(this.state.userId, this.disposeSignal);
|
|
207
|
+
this.model = new UserModel(user);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
protected onDispose() { this.model?.dispose(); }
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
**Never** write `public model!: UserModel` — the `!` lies to TypeScript about runtime state and causes `useField`/`useModel` to crash on the first render with "Cannot read properties of undefined." Either make the Model non-nullable via constructor creation, or declare it nullable and guard the component with `if (!vm.model) return <Spinner />`.
|
|
215
|
+
|
|
182
216
|
## Composable Helpers Pattern
|
|
183
217
|
|
|
184
218
|
Declare helpers as ViewModel instance properties. They have `subscribe()` so they're auto-tracked — getters that read them recompute when helper state changes.
|