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.
Files changed (61) hide show
  1. package/BEST_PRACTICES.md +53 -4
  2. package/agent-config/claude-code/agents/mvc-kit-architect.md +2 -2
  3. package/agent-config/claude-code/skills/{guide → mvc-kit}/SKILL.md +2 -2
  4. package/agent-config/claude-code/skills/{guide → mvc-kit}/anti-patterns.md +47 -0
  5. package/agent-config/claude-code/skills/{guide → mvc-kit}/patterns.md +11 -0
  6. package/agent-config/claude-code/skills/{guide → mvc-kit}/recipes.md +103 -22
  7. package/agent-config/claude-code/skills/{guide → mvc-kit}/testing.md +3 -2
  8. package/agent-config/claude-code/skills/{review → mvc-kit-review}/checklist.md +7 -6
  9. package/agent-config/copilot/copilot-instructions.md +34 -0
  10. package/agent-config/cursor/cursorrules +34 -0
  11. package/agent-config/lib/install-claude.mjs +39 -116
  12. package/dist/react/components/DataTable.cjs +5 -9
  13. package/dist/react/components/DataTable.cjs.map +1 -1
  14. package/dist/react/components/DataTable.d.ts.map +1 -1
  15. package/dist/react/components/DataTable.js +5 -9
  16. package/dist/react/components/DataTable.js.map +1 -1
  17. package/dist/react/use-instance.cjs +6 -3
  18. package/dist/react/use-instance.cjs.map +1 -1
  19. package/dist/react/use-instance.d.ts.map +1 -1
  20. package/dist/react/use-instance.js +6 -3
  21. package/dist/react/use-instance.js.map +1 -1
  22. package/dist/react/use-local.cjs +1 -0
  23. package/dist/react/use-local.cjs.map +1 -1
  24. package/dist/react/use-local.js +1 -0
  25. package/dist/react/use-local.js.map +1 -1
  26. package/dist/react/use-model.cjs +34 -8
  27. package/dist/react/use-model.cjs.map +1 -1
  28. package/dist/react/use-model.d.ts.map +1 -1
  29. package/dist/react/use-model.js +34 -8
  30. package/dist/react/use-model.js.map +1 -1
  31. package/dist/react/use-subscribe-only.cjs +3 -2
  32. package/dist/react/use-subscribe-only.cjs.map +1 -1
  33. package/dist/react/use-subscribe-only.d.ts.map +1 -1
  34. package/dist/react/use-subscribe-only.js +3 -2
  35. package/dist/react/use-subscribe-only.js.map +1 -1
  36. package/examples/react/AuthExample/src/components/AdminPage.tsx +3 -1
  37. package/examples/react/AuthExample/src/components/DashboardPage.tsx +4 -2
  38. package/examples/react/AuthExample/src/components/ProfilePage.tsx +2 -1
  39. package/package.json +1 -1
  40. package/src/Model.md +55 -6
  41. package/src/Service.md +4 -1
  42. package/src/react/components/DataTable.tsx +9 -13
  43. package/src/react/use-instance.ts +14 -3
  44. package/src/react/use-local.ts +2 -1
  45. package/src/react/use-model.md +51 -4
  46. package/src/react/use-model.test.tsx +86 -0
  47. package/src/react/use-model.ts +44 -15
  48. package/src/react/use-subscribe-only.ts +3 -2
  49. /package/agent-config/claude-code/skills/{guide → mvc-kit}/api-reference.md +0 -0
  50. /package/agent-config/claude-code/skills/{review → mvc-kit-review}/SKILL.md +0 -0
  51. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/SKILL.md +0 -0
  52. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/channel.md +0 -0
  53. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/collection.md +0 -0
  54. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/controller.md +0 -0
  55. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/eventbus.md +0 -0
  56. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/model.md +0 -0
  57. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/page-component.md +0 -0
  58. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/persistent-collection.md +0 -0
  59. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/resource.md +0 -0
  60. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/service.md +0 -0
  61. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/viewmodel.md +0 -0
@@ -125,11 +125,20 @@ function UserForm() {
125
125
 
126
126
  ## Model Inside a ViewModel
127
127
 
128
- The typical form-page pattern: a [ViewModel](../ViewModel.md) handles async operations (load, save, delete) and coordination, while the Model handles the editing state. The ViewModel owns the Model and disposes it in `onDispose()`.
128
+ The typical form pattern: a [ViewModel](../ViewModel.md) handles async operations (save, delete) and coordination, while the Model handles the editing state. The ViewModel owns the Model and disposes it in `onDispose()`. Which shape you use depends on whether the form's initial data is already available:
129
+
130
+ | If the initial data is... | Shape |
131
+ |---|---|
132
+ | **Available at construction** (create modal, edit modal receiving an entity prop) | Create the Model in the **constructor** as `readonly`. Non-nullable, no guard. |
133
+ | **Fetched by ID** after mount (edit page that loads from the server) | Declare it `\| null = null` and create it in `onInit()`. Component must guard. |
134
+
135
+ > **Never** write `public model!: FormModel`. The `!` lies to TypeScript about runtime state — first render runs before `onInit`, `vm.model` is `undefined`, and `useField`/`useModel` crash. Pick one of the two shapes below.
136
+
137
+ ### Shape 1 — Data available at construction (preferred when applicable)
129
138
 
130
139
  ```typescript
131
140
  interface EditState {
132
- draft: UserState | null;
141
+ existing: UserState | null; // null → create, non-null → edit
133
142
  }
134
143
 
135
144
  interface EditEvents {
@@ -137,7 +146,45 @@ interface EditEvents {
137
146
  }
138
147
 
139
148
  class EditUserViewModel extends ViewModel<EditState, EditEvents> {
140
- public model!: UserFormModel;
149
+ // `readonly` refers to the reference — the model's state still changes via set().
150
+ public readonly model: UserFormModel;
151
+ private service = singleton(UserService);
152
+
153
+ constructor(initialState: EditState) {
154
+ super(initialState);
155
+ this.model = new UserFormModel(initialState.existing ?? INITIAL_FORM_STATE);
156
+ }
157
+
158
+ async save() {
159
+ if (!this.model.valid) return;
160
+ const result = await this.service.save(this.model.state, this.disposeSignal);
161
+ this.model.commit();
162
+ this.emit('saved', { id: result.id });
163
+ }
164
+
165
+ protected onDispose() {
166
+ this.model.dispose();
167
+ }
168
+ }
169
+ ```
170
+
171
+ ```tsx
172
+ function EditUserModal({ existing, onClose }: Props) {
173
+ const [, vm] = useLocal(EditUserViewModel, { existing });
174
+ const { loading: saving } = vm.async.save;
175
+
176
+ useEvent(vm, 'saved', onClose);
177
+
178
+ // No guard — vm.model exists from the first render.
179
+ return <EditUserForm model={vm.model} onSave={vm.save} saving={saving} />;
180
+ }
181
+ ```
182
+
183
+ ### Shape 2 — Data fetched by ID
184
+
185
+ ```typescript
186
+ class EditUserViewModel extends ViewModel<EditState, EditEvents> {
187
+ public model: UserFormModel | null = null;
141
188
  private service = singleton(UserService);
142
189
 
143
190
  protected async onInit() {
@@ -147,7 +194,7 @@ class EditUserViewModel extends ViewModel<EditState, EditEvents> {
147
194
  }
148
195
 
149
196
  async save() {
150
- if (!this.model.valid) return;
197
+ if (!this.model || !this.model.valid) return;
151
198
  await this.service.update(this.userId, this.model.state, this.disposeSignal);
152
199
  this.model.commit();
153
200
  this.emit('saved', { id: this.userId });
@@ -392,3 +392,89 @@ describe('type-level: useField rejects invalid field names', () => {
392
392
  _useField(model, 'age');
393
393
  });
394
394
  });
395
+
396
+ describe('DEV assertions: undefined/null model', () => {
397
+ // React logs render errors to console.error even when an ErrorBoundary catches them.
398
+ // Silence the noise for these expected-throw tests.
399
+ let errorSpy: ReturnType<typeof vi.spyOn>;
400
+ beforeEach(() => {
401
+ errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
402
+ });
403
+ afterEach(() => {
404
+ errorSpy.mockRestore();
405
+ });
406
+
407
+ class ErrorBoundary extends (require('react').Component as typeof import('react').Component)<
408
+ { children: import('react').ReactNode; onError: (e: Error) => void },
409
+ { err: Error | null }
410
+ > {
411
+ state = { err: null as Error | null };
412
+ static getDerivedStateFromError(err: Error) { return { err }; }
413
+ componentDidCatch(err: Error) { this.props.onError(err); }
414
+ render() {
415
+ return this.state.err ? null : this.props.children;
416
+ }
417
+ }
418
+
419
+ it('useField throws a diagnostic error when model is undefined', () => {
420
+ function Child({ model }: { model: any }) {
421
+ useField(model, 'name');
422
+ return null;
423
+ }
424
+ let caught: Error | null = null;
425
+ render(
426
+ <ErrorBoundary onError={e => { caught = e; }}>
427
+ <Child model={undefined} />
428
+ </ErrorBoundary>,
429
+ );
430
+ expect(caught).not.toBeNull();
431
+ expect(caught!.message).toContain('useField');
432
+ expect(caught!.message).toContain('undefined/null model');
433
+ expect(caught!.message).toContain('public model!');
434
+ });
435
+
436
+ it('useField throws when model is null', () => {
437
+ function Child({ model }: { model: any }) {
438
+ useField(model, 'name');
439
+ return null;
440
+ }
441
+ let caught: Error | null = null;
442
+ render(
443
+ <ErrorBoundary onError={e => { caught = e; }}>
444
+ <Child model={null} />
445
+ </ErrorBoundary>,
446
+ );
447
+ expect(caught).not.toBeNull();
448
+ expect(caught!.message).toContain('useField');
449
+ });
450
+
451
+ it('useModel throws when factory returns undefined', () => {
452
+ function Child() {
453
+ useModel(() => undefined as any);
454
+ return null;
455
+ }
456
+ let caught: Error | null = null;
457
+ render(
458
+ <ErrorBoundary onError={e => { caught = e; }}>
459
+ <Child />
460
+ </ErrorBoundary>,
461
+ );
462
+ expect(caught).not.toBeNull();
463
+ expect(caught!.message).toContain('useModel');
464
+ });
465
+
466
+ it('useModelRef throws when factory returns undefined', () => {
467
+ function Child() {
468
+ useModelRef(() => undefined as any);
469
+ return null;
470
+ }
471
+ let caught: Error | null = null;
472
+ render(
473
+ <ErrorBoundary onError={e => { caught = e; }}>
474
+ <Child />
475
+ </ErrorBoundary>,
476
+ );
477
+ expect(caught).not.toBeNull();
478
+ expect(caught!.message).toContain('useModelRef');
479
+ });
480
+ });
@@ -4,6 +4,23 @@ import type { ValidationErrors } from '../types';
4
4
  import type { StateOf } from './types';
5
5
  import { isInitializable } from './guards';
6
6
 
7
+ const __DEV__ = typeof __MVC_KIT_DEV__ !== 'undefined' && __MVC_KIT_DEV__;
8
+
9
+ function assertModelExists(
10
+ model: unknown,
11
+ hookName: 'useModel' | 'useModelRef' | 'useField',
12
+ ): asserts model {
13
+ if (model) return;
14
+ throw new Error(
15
+ `${hookName}: received an undefined/null model. ` +
16
+ 'This usually means the component rendered before the ViewModel finished creating the model. ' +
17
+ 'Either (a) create the model in the ViewModel constructor when the initial data is available ' +
18
+ '(so it exists from the first render), or (b) if the model is created in onInit() after an async ' +
19
+ 'fetch, guard the component render with `if (!vm.model) return <Spinner />` before calling this hook. ' +
20
+ 'Do not use `public model!: FormModel` — the `!` lies to TypeScript about runtime state.',
21
+ );
22
+ }
23
+
7
24
  /** Return type of `useModel`, providing state, validation, and model access. */
8
25
  export interface ModelHandle<S extends object, M extends Model<S>> {
9
26
  state: S;
@@ -22,25 +39,35 @@ export function useModel<M extends Model<any>>(
22
39
  const modelRef = useRef<M | null>(null);
23
40
  const mountedRef = useRef(false);
24
41
 
25
- if (!modelRef.current || modelRef.current.disposed) {
26
- modelRef.current = factory();
42
+ let model = modelRef.current;
43
+ if (!model || model.disposed) {
44
+ model = factory();
45
+ if (__DEV__) assertModelExists(model, 'useModel');
46
+ modelRef.current = model;
27
47
  }
28
48
 
29
49
  const modelSubscribe = useCallback(
30
- (onStoreChange: () => void) => modelRef.current!.subscribe(onStoreChange),
50
+ (onStoreChange: () => void) => {
51
+ const m = modelRef.current;
52
+ return m ? m.subscribe(onStoreChange) : () => {};
53
+ },
31
54
  []
32
55
  );
33
56
  const modelSnapshot = useCallback(
34
- () => modelRef.current!.state,
57
+ () => {
58
+ const m = modelRef.current;
59
+ if (!m) throw new Error('useModel: model accessed before creation');
60
+ return m.state;
61
+ },
35
62
  []
36
63
  );
37
64
  useSyncExternalStore(modelSubscribe, modelSnapshot, modelSnapshot);
38
65
 
39
66
  useEffect(() => {
67
+ const m = modelRef.current;
68
+ if (!m) return;
40
69
  mountedRef.current = true;
41
- if (isInitializable(modelRef.current)) {
42
- modelRef.current.init();
43
- }
70
+ if (isInitializable(m)) m.init();
44
71
  return () => {
45
72
  mountedRef.current = false;
46
73
  setTimeout(() => {
@@ -51,8 +78,6 @@ export function useModel<M extends Model<any>>(
51
78
  };
52
79
  }, []);
53
80
 
54
- const model = modelRef.current;
55
-
56
81
  return {
57
82
  state: model.state,
58
83
  errors: model.errors,
@@ -74,15 +99,18 @@ export function useModelRef<M extends Model<any>>(factory: () => M): M {
74
99
  const modelRef = useRef<M | null>(null);
75
100
  const mountedRef = useRef(false);
76
101
 
77
- if (!modelRef.current || modelRef.current.disposed) {
78
- modelRef.current = factory();
102
+ let model = modelRef.current;
103
+ if (!model || model.disposed) {
104
+ model = factory();
105
+ if (__DEV__) assertModelExists(model, 'useModelRef');
106
+ modelRef.current = model;
79
107
  }
80
108
 
81
109
  useEffect(() => {
110
+ const m = modelRef.current;
111
+ if (!m) return;
82
112
  mountedRef.current = true;
83
- if (isInitializable(modelRef.current)) {
84
- modelRef.current.init();
85
- }
113
+ if (isInitializable(m)) m.init();
86
114
  return () => {
87
115
  mountedRef.current = false;
88
116
  setTimeout(() => {
@@ -93,7 +121,7 @@ export function useModelRef<M extends Model<any>>(factory: () => M): M {
93
121
  };
94
122
  }, []);
95
123
 
96
- return modelRef.current;
124
+ return model;
97
125
  }
98
126
 
99
127
  /** Return type of `useField`, providing a single field's value, error, and setter. */
@@ -110,6 +138,7 @@ export function useField<S extends object, K extends keyof S>(
110
138
  model: Model<S>,
111
139
  field: K
112
140
  ): FieldHandle<S[K]> {
141
+ if (__DEV__) assertModelExists(model, 'useField');
113
142
  // Track the field value and error for comparison
114
143
  const getSnapshot = useCallback(() => {
115
144
  return {
@@ -21,18 +21,19 @@ export function useSubscribeOnly(
21
21
 
22
22
  if (!ref.current || ref.current.target !== target) {
23
23
  const version = { current: ref.current?.version ?? 0 };
24
- ref.current = {
24
+ const entry: SubscribeOnlyRef = {
25
25
  target,
26
26
  version: version.current,
27
27
  subscribe: (onStoreChange: () => void) => {
28
28
  return target.subscribe(() => {
29
29
  version.current++;
30
- ref.current!.version = version.current;
30
+ entry.version = version.current;
31
31
  onStoreChange();
32
32
  });
33
33
  },
34
34
  getSnapshot: () => version.current,
35
35
  };
36
+ ref.current = entry;
36
37
  }
37
38
 
38
39
  useSyncExternalStore(ref.current.subscribe, ref.current.getSnapshot, SERVER_SNAPSHOT);