mvc-kit 2.13.2 → 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/skills/mvc-kit/SKILL.md +1 -1
- package/agent-config/claude-code/skills/mvc-kit/anti-patterns.md +47 -0
- package/agent-config/claude-code/skills/mvc-kit/patterns.md +11 -0
- package/agent-config/claude-code/skills/mvc-kit/recipes.md +103 -22
- package/agent-config/claude-code/skills/mvc-kit/testing.md +3 -2
- package/agent-config/claude-code/skills/mvc-kit-review/checklist.md +7 -6
- package/agent-config/copilot/copilot-instructions.md +34 -0
- package/agent-config/cursor/cursorrules +34 -0
- 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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-subscribe-only.js","names":[],"sources":["../../src/react/use-subscribe-only.ts"],"sourcesContent":["import { useSyncExternalStore, useRef } from 'react';\n\ninterface SubscribeOnlyRef {\n target: { subscribe(cb: () => void): () => void };\n version: number;\n subscribe: (onStoreChange: () => void) => () => void;\n getSnapshot: () => number;\n}\n\nconst SERVER_SNAPSHOT = () => 0;\n\n/**\n * Subscribe to a notification-only object (has subscribe() but no state).\n * Triggers React re-renders via version counter when the target notifies.\n * @internal\n */\nexport function useSubscribeOnly(\n target: { subscribe(cb: () => void): () => void },\n): void {\n const ref = useRef<SubscribeOnlyRef | null>(null);\n\n if (!ref.current || ref.current.target !== target) {\n const version = { current: ref.current?.version ?? 0 };\n
|
|
1
|
+
{"version":3,"file":"use-subscribe-only.js","names":[],"sources":["../../src/react/use-subscribe-only.ts"],"sourcesContent":["import { useSyncExternalStore, useRef } from 'react';\n\ninterface SubscribeOnlyRef {\n target: { subscribe(cb: () => void): () => void };\n version: number;\n subscribe: (onStoreChange: () => void) => () => void;\n getSnapshot: () => number;\n}\n\nconst SERVER_SNAPSHOT = () => 0;\n\n/**\n * Subscribe to a notification-only object (has subscribe() but no state).\n * Triggers React re-renders via version counter when the target notifies.\n * @internal\n */\nexport function useSubscribeOnly(\n target: { subscribe(cb: () => void): () => void },\n): void {\n const ref = useRef<SubscribeOnlyRef | null>(null);\n\n if (!ref.current || ref.current.target !== target) {\n const version = { current: ref.current?.version ?? 0 };\n const entry: SubscribeOnlyRef = {\n target,\n version: version.current,\n subscribe: (onStoreChange: () => void) => {\n return target.subscribe(() => {\n version.current++;\n entry.version = version.current;\n onStoreChange();\n });\n },\n getSnapshot: () => version.current,\n };\n ref.current = entry;\n }\n\n useSyncExternalStore(ref.current.subscribe, ref.current.getSnapshot, SERVER_SNAPSHOT);\n}\n"],"mappings":";;AASA,IAAM,wBAAwB;;;;;;AAO9B,SAAgB,iBACd,QACM;CACN,MAAM,MAAM,OAAgC,KAAK;AAEjD,KAAI,CAAC,IAAI,WAAW,IAAI,QAAQ,WAAW,QAAQ;EACjD,MAAM,UAAU,EAAE,SAAS,IAAI,SAAS,WAAW,GAAG;EACtD,MAAM,QAA0B;GAC9B;GACA,SAAS,QAAQ;GACjB,YAAY,kBAA8B;AACxC,WAAO,OAAO,gBAAgB;AAC5B,aAAQ;AACR,WAAM,UAAU,QAAQ;AACxB,oBAAe;MACf;;GAEJ,mBAAmB,QAAQ;GAC5B;AACD,MAAI,UAAU;;AAGhB,sBAAqB,IAAI,QAAQ,WAAW,IAAI,QAAQ,aAAa,gBAAgB"}
|
|
@@ -3,6 +3,8 @@ import { AuthViewModel } from '../viewmodels/AuthViewModel';
|
|
|
3
3
|
|
|
4
4
|
export function AdminPage() {
|
|
5
5
|
const [state, vm] = useSingleton(AuthViewModel);
|
|
6
|
+
const { user } = state;
|
|
7
|
+
if (!user) return null; // AuthGuard renders this page only when signed in
|
|
6
8
|
|
|
7
9
|
// Role check is done inside the page, not via a route wrapper.
|
|
8
10
|
// This keeps routing simple and the access-denied message inline.
|
|
@@ -13,7 +15,7 @@ export function AdminPage() {
|
|
|
13
15
|
<h2>Access Denied</h2>
|
|
14
16
|
<p>
|
|
15
17
|
You are signed in as <strong>{vm.displayName}</strong> with
|
|
16
|
-
the <span className={`badge badge-${
|
|
18
|
+
the <span className={`badge badge-${user.role}`}>{user.role}</span> role.
|
|
17
19
|
</p>
|
|
18
20
|
<p>This page requires the <span className="badge badge-admin">admin</span> role.</p>
|
|
19
21
|
</div>
|
|
@@ -3,6 +3,8 @@ import { AuthViewModel } from '../viewmodels/AuthViewModel';
|
|
|
3
3
|
|
|
4
4
|
export function DashboardPage() {
|
|
5
5
|
const [state, vm] = useSingleton(AuthViewModel);
|
|
6
|
+
const { user } = state;
|
|
7
|
+
if (!user) return null; // AuthGuard renders this page only when signed in
|
|
6
8
|
|
|
7
9
|
return (
|
|
8
10
|
<div className="page-content">
|
|
@@ -11,8 +13,8 @@ export function DashboardPage() {
|
|
|
11
13
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
|
12
14
|
<h2 style={{ marginBottom: '0.5rem' }}>Welcome, {vm.displayName}!</h2>
|
|
13
15
|
<p style={{ color: 'var(--color-text-secondary)' }}>
|
|
14
|
-
You are signed in as <strong>{
|
|
15
|
-
the <span className={`badge badge-${
|
|
16
|
+
You are signed in as <strong>{user.email}</strong> with
|
|
17
|
+
the <span className={`badge badge-${user.role}`}>{user.role}</span> role.
|
|
16
18
|
</p>
|
|
17
19
|
</div>
|
|
18
20
|
|
|
@@ -3,7 +3,8 @@ import { AuthViewModel } from '../viewmodels/AuthViewModel';
|
|
|
3
3
|
|
|
4
4
|
export function ProfilePage() {
|
|
5
5
|
const [state, vm] = useSingleton(AuthViewModel);
|
|
6
|
-
const user = state
|
|
6
|
+
const { user } = state;
|
|
7
|
+
if (!user) return null; // AuthGuard renders this page only when signed in
|
|
7
8
|
|
|
8
9
|
return (
|
|
9
10
|
<div className="page-content">
|
package/package.json
CHANGED
package/src/Model.md
CHANGED
|
@@ -386,11 +386,22 @@ The returned `FieldHandle` provides:
|
|
|
386
386
|
|
|
387
387
|
## Model Inside a ViewModel
|
|
388
388
|
|
|
389
|
-
The typical pattern for a form
|
|
389
|
+
The typical pattern for a form: the ViewModel handles async operations and coordination, the Model handles editing state. There are two shapes, and the right choice depends on whether the form's initial data is already in hand.
|
|
390
|
+
|
|
391
|
+
| If the initial data is... | Shape |
|
|
392
|
+
|---|---|
|
|
393
|
+
| **Available at construction** (create modal, edit modal taking an entity prop) | Create the Model in the **constructor** as `readonly`. Non-nullable, no guard. |
|
|
394
|
+
| **Fetched by ID** after mount (edit page that loads from the server) | Declare it `\| null = null` and create it in `onInit()`. Component must guard. |
|
|
395
|
+
|
|
396
|
+
> **Never** write `public model!: FormModel`. The `!` definite-assignment assertion lies to TypeScript about runtime state — first render runs before `onInit`, so `vm.model` is `undefined`, and `useField` / `useModel` crash with "Cannot read properties of undefined."
|
|
397
|
+
|
|
398
|
+
### Shape 1 — Data available at construction
|
|
399
|
+
|
|
400
|
+
The ViewModel receives everything it needs via `useLocal`'s initial state. Create the Model in the constructor so it exists from the first render onward.
|
|
390
401
|
|
|
391
402
|
```typescript
|
|
392
403
|
interface EditState {
|
|
393
|
-
|
|
404
|
+
existing: UserState | null;
|
|
394
405
|
}
|
|
395
406
|
|
|
396
407
|
interface EditEvents {
|
|
@@ -398,7 +409,47 @@ interface EditEvents {
|
|
|
398
409
|
}
|
|
399
410
|
|
|
400
411
|
class EditUserViewModel extends ViewModel<EditState, EditEvents> {
|
|
401
|
-
|
|
412
|
+
// `readonly` refers to the *reference* — the model's state still changes via set().
|
|
413
|
+
public readonly model: UserFormModel;
|
|
414
|
+
private service = singleton(UserService);
|
|
415
|
+
|
|
416
|
+
constructor(initialState: EditState) {
|
|
417
|
+
super(initialState);
|
|
418
|
+
this.model = new UserFormModel(initialState.existing ?? INITIAL_FORM_STATE);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async save() {
|
|
422
|
+
if (!this.model.valid) return;
|
|
423
|
+
const result = await this.service.save(this.model.state, this.disposeSignal);
|
|
424
|
+
this.model.commit();
|
|
425
|
+
this.emit('saved', { id: result.id });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
protected onDispose() {
|
|
429
|
+
this.model.dispose();
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
```tsx
|
|
435
|
+
function EditUserModal({ existing, onClose }: Props) {
|
|
436
|
+
const [, vm] = useLocal(EditUserViewModel, { existing });
|
|
437
|
+
const { loading: saving } = vm.async.save;
|
|
438
|
+
|
|
439
|
+
useEvent(vm, 'saved', onClose);
|
|
440
|
+
|
|
441
|
+
// No guard — vm.model is non-null from the first render.
|
|
442
|
+
return <EditUserForm model={vm.model} onSave={vm.save} saving={saving} />;
|
|
443
|
+
}
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
### Shape 2 — Data fetched by ID
|
|
447
|
+
|
|
448
|
+
Only an ID is known at construction; the entity is loaded in `onInit()`. The Model is nullable until the fetch resolves.
|
|
449
|
+
|
|
450
|
+
```typescript
|
|
451
|
+
class EditUserViewModel extends ViewModel<EditState, EditEvents> {
|
|
452
|
+
public model: UserFormModel | null = null;
|
|
402
453
|
private service = singleton(UserService);
|
|
403
454
|
|
|
404
455
|
protected async onInit() {
|
|
@@ -408,7 +459,7 @@ class EditUserViewModel extends ViewModel<EditState, EditEvents> {
|
|
|
408
459
|
}
|
|
409
460
|
|
|
410
461
|
async save() {
|
|
411
|
-
if (!this.model.valid) return;
|
|
462
|
+
if (!this.model || !this.model.valid) return;
|
|
412
463
|
await this.service.update(this.userId, this.model.state, this.disposeSignal);
|
|
413
464
|
this.model.commit();
|
|
414
465
|
this.emit('saved', { id: this.userId });
|
|
@@ -420,8 +471,6 @@ class EditUserViewModel extends ViewModel<EditState, EditEvents> {
|
|
|
420
471
|
}
|
|
421
472
|
```
|
|
422
473
|
|
|
423
|
-
The component:
|
|
424
|
-
|
|
425
474
|
```tsx
|
|
426
475
|
function EditUserPage() {
|
|
427
476
|
const [state, vm] = useLocal(EditUserViewModel, { draft: null });
|
package/src/Service.md
CHANGED
|
@@ -153,13 +153,9 @@ export function DataTable<T>({
|
|
|
153
153
|
// ── Resolve props ──
|
|
154
154
|
const resolvedSelection = selection ? resolveSelectionProp(selection) : undefined;
|
|
155
155
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const resolved = resolveSortProp(sort, onSort);
|
|
160
|
-
resolvedSorts = resolved.sorts;
|
|
161
|
-
resolvedOnSort = resolved.onSort;
|
|
162
|
-
}
|
|
156
|
+
const resolvedSort = sort ? resolveSortProp(sort, onSort) : undefined;
|
|
157
|
+
const resolvedSorts = resolvedSort?.sorts;
|
|
158
|
+
const resolvedOnSort = resolvedSort?.onSort;
|
|
163
159
|
|
|
164
160
|
let displayItems = items;
|
|
165
161
|
let paginationInfo: PaginationInfo | undefined;
|
|
@@ -171,8 +167,8 @@ export function DataTable<T>({
|
|
|
171
167
|
|
|
172
168
|
// Selection: check indeterminate state
|
|
173
169
|
const allKeys = resolvedSelection ? displayItems.map(item => keyOf(item)) : [];
|
|
174
|
-
const allSelected = resolvedSelection && allKeys.length > 0 && allKeys.every(k => resolvedSelection
|
|
175
|
-
const someSelected = resolvedSelection && allKeys.some(k => resolvedSelection
|
|
170
|
+
const allSelected = !!resolvedSelection && allKeys.length > 0 && allKeys.every(k => resolvedSelection.selected.has(k));
|
|
171
|
+
const someSelected = !!resolvedSelection && allKeys.some(k => resolvedSelection.selected.has(k));
|
|
176
172
|
|
|
177
173
|
return (
|
|
178
174
|
<div data-component="data-table" className={className}>
|
|
@@ -187,7 +183,7 @@ export function DataTable<T>({
|
|
|
187
183
|
ref={(el) => {
|
|
188
184
|
if (el) el.indeterminate = !!someSelected && !allSelected;
|
|
189
185
|
}}
|
|
190
|
-
onChange={() => resolvedSelection
|
|
186
|
+
onChange={() => resolvedSelection.onToggleAll(allKeys)}
|
|
191
187
|
aria-label="Select all"
|
|
192
188
|
/>
|
|
193
189
|
</th>
|
|
@@ -208,13 +204,13 @@ export function DataTable<T>({
|
|
|
208
204
|
aria-sort={isSortable ? getAriaSortValue(col.key, resolvedSorts) : undefined}
|
|
209
205
|
>
|
|
210
206
|
{isSortable ? (
|
|
211
|
-
<button type="button" onClick={() => resolvedOnSort
|
|
207
|
+
<button type="button" onClick={() => resolvedOnSort(col.key)}>
|
|
212
208
|
{col.header}
|
|
213
209
|
{renderSortIndicator?.({
|
|
214
210
|
active: isActive,
|
|
215
211
|
direction: sortDesc?.direction ?? 'asc',
|
|
216
212
|
index: sortIndex,
|
|
217
|
-
onToggle: () => resolvedOnSort
|
|
213
|
+
onToggle: () => resolvedOnSort(col.key),
|
|
218
214
|
})}
|
|
219
215
|
</button>
|
|
220
216
|
) : (
|
|
@@ -237,7 +233,7 @@ export function DataTable<T>({
|
|
|
237
233
|
<input
|
|
238
234
|
type="checkbox"
|
|
239
235
|
checked={!!isSelected}
|
|
240
|
-
onChange={() => resolvedSelection
|
|
236
|
+
onChange={() => resolvedSelection.onToggle(key)}
|
|
241
237
|
aria-label={`Select row ${key}`}
|
|
242
238
|
/>
|
|
243
239
|
</td>
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useSyncExternalStore, useRef } from 'react';
|
|
2
2
|
import type { Subscribable } from '../types';
|
|
3
3
|
|
|
4
|
+
const __DEV__ = typeof __MVC_KIT_DEV__ !== 'undefined' && __MVC_KIT_DEV__;
|
|
5
|
+
|
|
4
6
|
function hasAsyncSubscription(obj: unknown): obj is { subscribeAsync(cb: () => void): () => void } {
|
|
5
7
|
return (
|
|
6
8
|
obj !== null &&
|
|
@@ -27,24 +29,32 @@ interface InstanceRef<S> {
|
|
|
27
29
|
* trigger React re-renders.
|
|
28
30
|
*/
|
|
29
31
|
export function useInstance<S>(subscribable: Subscribable<S>): S {
|
|
32
|
+
if (__DEV__ && !subscribable) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'useInstance: received an undefined/null subscribable. ' +
|
|
35
|
+
'Make sure the instance is created synchronously (in a parent ViewModel\'s constructor or as a singleton) ' +
|
|
36
|
+
'before the component that calls useInstance renders. ' +
|
|
37
|
+
'If it\'s created in onInit(), guard the component with `if (!vm.child) return <Spinner />` first.',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
30
40
|
const ref = useRef<InstanceRef<S> | null>(null);
|
|
31
41
|
|
|
32
42
|
if (!ref.current || ref.current.subscribable !== subscribable) {
|
|
33
43
|
const version = { current: ref.current?.version ?? 0 };
|
|
34
|
-
|
|
44
|
+
const entry: InstanceRef<S> = {
|
|
35
45
|
version: version.current,
|
|
36
46
|
subscribable,
|
|
37
47
|
subscribe: (onStoreChange: () => void) => {
|
|
38
48
|
const unsub1 = subscribable.subscribe(() => {
|
|
39
49
|
version.current++;
|
|
40
|
-
|
|
50
|
+
entry.version = version.current;
|
|
41
51
|
onStoreChange();
|
|
42
52
|
});
|
|
43
53
|
let unsub2: (() => void) | undefined;
|
|
44
54
|
if (hasAsyncSubscription(subscribable)) {
|
|
45
55
|
unsub2 = subscribable.subscribeAsync(() => {
|
|
46
56
|
version.current++;
|
|
47
|
-
|
|
57
|
+
entry.version = version.current;
|
|
48
58
|
onStoreChange();
|
|
49
59
|
});
|
|
50
60
|
}
|
|
@@ -52,6 +62,7 @@ export function useInstance<S>(subscribable: Subscribable<S>): S {
|
|
|
52
62
|
},
|
|
53
63
|
getSnapshot: () => version.current,
|
|
54
64
|
};
|
|
65
|
+
ref.current = entry;
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
useSyncExternalStore(ref.current.subscribe, ref.current.getSnapshot, SERVER_SNAPSHOT);
|
package/src/react/use-local.ts
CHANGED
|
@@ -133,7 +133,8 @@ export function useLocal<T extends Disposable, S = StateOf<T>>(
|
|
|
133
133
|
|
|
134
134
|
// ── Effect: init + deferred cleanup ──
|
|
135
135
|
useEffect(() => {
|
|
136
|
-
const instance = instanceRef.current
|
|
136
|
+
const instance = instanceRef.current;
|
|
137
|
+
if (!instance) return;
|
|
137
138
|
mountedRef.current = true;
|
|
138
139
|
if (isInitializable(instance)) {
|
|
139
140
|
instance.init();
|
package/src/react/use-model.md
CHANGED
|
@@ -125,11 +125,20 @@ function UserForm() {
|
|
|
125
125
|
|
|
126
126
|
## Model Inside a ViewModel
|
|
127
127
|
|
|
128
|
-
The typical form
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/react/use-model.ts
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
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) =>
|
|
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
|
-
() =>
|
|
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(
|
|
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
|
-
|
|
78
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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);
|