mvc-kit 2.12.0 → 2.12.1
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/agent-config/bin/postinstall.mjs +5 -3
- package/agent-config/bin/setup.mjs +3 -4
- package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
- package/agent-config/lib/install-claude.mjs +10 -33
- package/dist/Model.cjs +9 -1
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.d.ts +1 -1
- package/dist/Model.d.ts.map +1 -1
- package/dist/Model.js +9 -1
- package/dist/Model.js.map +1 -1
- package/dist/ViewModel.cjs +9 -1
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.d.ts +1 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/ViewModel.js +9 -1
- package/dist/ViewModel.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +3 -0
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +3 -0
- package/dist/mvc-kit.js.map +1 -1
- package/dist/produceDraft.cjs +105 -0
- package/dist/produceDraft.cjs.map +1 -0
- package/dist/produceDraft.d.ts +19 -0
- package/dist/produceDraft.d.ts.map +1 -0
- package/dist/produceDraft.js +105 -0
- package/dist/produceDraft.js.map +1 -0
- package/package.json +4 -2
- package/src/Channel.md +408 -0
- package/src/Channel.test.ts +957 -0
- package/src/Channel.ts +429 -0
- package/src/Collection.md +533 -0
- package/src/Collection.test.ts +1559 -0
- package/src/Collection.ts +653 -0
- package/src/Controller.md +306 -0
- package/src/Controller.test.ts +380 -0
- package/src/Controller.ts +90 -0
- package/src/EventBus.md +308 -0
- package/src/EventBus.test.ts +295 -0
- package/src/EventBus.ts +110 -0
- package/src/Feed.md +218 -0
- package/src/Feed.test.ts +442 -0
- package/src/Feed.ts +101 -0
- package/src/Model.md +524 -0
- package/src/Model.test.ts +642 -0
- package/src/Model.ts +260 -0
- package/src/Pagination.md +168 -0
- package/src/Pagination.test.ts +244 -0
- package/src/Pagination.ts +92 -0
- package/src/Pending.md +380 -0
- package/src/Pending.test.ts +1719 -0
- package/src/Pending.ts +390 -0
- package/src/PersistentCollection.md +183 -0
- package/src/PersistentCollection.test.ts +649 -0
- package/src/PersistentCollection.ts +375 -0
- package/src/Resource.ViewModel.test.ts +503 -0
- package/src/Resource.md +239 -0
- package/src/Resource.test.ts +786 -0
- package/src/Resource.ts +231 -0
- package/src/Selection.md +155 -0
- package/src/Selection.test.ts +326 -0
- package/src/Selection.ts +117 -0
- package/src/Service.md +440 -0
- package/src/Service.test.ts +241 -0
- package/src/Service.ts +72 -0
- package/src/Sorting.md +170 -0
- package/src/Sorting.test.ts +334 -0
- package/src/Sorting.ts +135 -0
- package/src/Trackable.md +166 -0
- package/src/Trackable.test.ts +236 -0
- package/src/Trackable.ts +129 -0
- package/src/ViewModel.async.test.ts +813 -0
- package/src/ViewModel.derived.test.ts +1583 -0
- package/src/ViewModel.md +1111 -0
- package/src/ViewModel.test.ts +1236 -0
- package/src/ViewModel.ts +800 -0
- package/src/bindPublicMethods.test.ts +126 -0
- package/src/bindPublicMethods.ts +48 -0
- package/src/env.d.ts +5 -0
- package/src/errors.test.ts +155 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +49 -0
- package/src/produceDraft.md +90 -0
- package/src/produceDraft.test.ts +394 -0
- package/src/produceDraft.ts +168 -0
- package/src/react/components/CardList.md +97 -0
- package/src/react/components/CardList.test.tsx +142 -0
- package/src/react/components/CardList.tsx +68 -0
- package/src/react/components/DataTable.md +179 -0
- package/src/react/components/DataTable.test.tsx +599 -0
- package/src/react/components/DataTable.tsx +267 -0
- package/src/react/components/InfiniteScroll.md +116 -0
- package/src/react/components/InfiniteScroll.test.tsx +218 -0
- package/src/react/components/InfiniteScroll.tsx +70 -0
- package/src/react/components/types.ts +90 -0
- package/src/react/derived.test.tsx +261 -0
- package/src/react/guards.ts +24 -0
- package/src/react/index.ts +40 -0
- package/src/react/provider.test.tsx +143 -0
- package/src/react/provider.tsx +55 -0
- package/src/react/strict-mode.test.tsx +266 -0
- package/src/react/types.ts +25 -0
- package/src/react/use-event-bus.md +214 -0
- package/src/react/use-event-bus.test.tsx +168 -0
- package/src/react/use-event-bus.ts +40 -0
- package/src/react/use-instance.md +204 -0
- package/src/react/use-instance.test.tsx +350 -0
- package/src/react/use-instance.ts +60 -0
- package/src/react/use-local.md +457 -0
- package/src/react/use-local.rapid-remount.test.tsx +503 -0
- package/src/react/use-local.test.tsx +692 -0
- package/src/react/use-local.ts +165 -0
- package/src/react/use-model.md +364 -0
- package/src/react/use-model.test.tsx +394 -0
- package/src/react/use-model.ts +161 -0
- package/src/react/use-singleton.md +415 -0
- package/src/react/use-singleton.test.tsx +296 -0
- package/src/react/use-singleton.ts +69 -0
- package/src/react/use-subscribe-only.ts +39 -0
- package/src/react/use-teardown.md +169 -0
- package/src/react/use-teardown.test.tsx +86 -0
- package/src/react/use-teardown.ts +27 -0
- package/src/react-native/NativeCollection.test.ts +250 -0
- package/src/react-native/NativeCollection.ts +138 -0
- package/src/react-native/index.ts +1 -0
- package/src/singleton.md +310 -0
- package/src/singleton.test.ts +204 -0
- package/src/singleton.ts +70 -0
- package/src/types.ts +70 -0
- package/src/walkPrototypeChain.ts +22 -0
- package/src/web/IndexedDBCollection.test.ts +235 -0
- package/src/web/IndexedDBCollection.ts +66 -0
- package/src/web/WebStorageCollection.test.ts +214 -0
- package/src/web/WebStorageCollection.ts +116 -0
- package/src/web/idb.ts +184 -0
- package/src/web/index.ts +2 -0
- package/src/wrapAsyncMethods.ts +249 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { render, screen, act } from '@testing-library/react';
|
|
5
|
+
import { vi } from 'vitest';
|
|
6
|
+
import { Model } from '../Model';
|
|
7
|
+
import type { ValidationErrors } from '../types';
|
|
8
|
+
import { useModel, useModelRef, useField } from './use-model';
|
|
9
|
+
|
|
10
|
+
interface FormState {
|
|
11
|
+
name: string;
|
|
12
|
+
email: string;
|
|
13
|
+
age: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class FormModel extends Model<FormState> {
|
|
17
|
+
protected validate(state: FormState): ValidationErrors<FormState> {
|
|
18
|
+
const errors: ValidationErrors<FormState> = {};
|
|
19
|
+
if (!state.name) errors.name = 'Name is required';
|
|
20
|
+
if (!state.email.includes('@')) errors.email = 'Invalid email';
|
|
21
|
+
if (state.age < 0) errors.age = 'Age must be positive';
|
|
22
|
+
return errors;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
setName(name: string) {
|
|
26
|
+
this.set({ name });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
setEmail(email: string) {
|
|
30
|
+
this.set({ email });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
setAge(age: number) {
|
|
34
|
+
this.set({ age });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Model with public set for useField testing
|
|
39
|
+
class PublicFormModel extends Model<FormState> {
|
|
40
|
+
protected validate(state: FormState): ValidationErrors<FormState> {
|
|
41
|
+
const errors: ValidationErrors<FormState> = {};
|
|
42
|
+
if (!state.name) errors.name = 'Name is required';
|
|
43
|
+
return errors;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Expose set as public for useField
|
|
47
|
+
public set(partial: Partial<FormState>) {
|
|
48
|
+
super.set(partial);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function FormComponent() {
|
|
53
|
+
const { state, errors, valid, dirty, model } = useModel(
|
|
54
|
+
() => new FormModel({ name: '', email: '', age: 0 })
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div>
|
|
59
|
+
<div data-testid="name">{state.name}</div>
|
|
60
|
+
<div data-testid="email">{state.email}</div>
|
|
61
|
+
<div data-testid="valid">{valid ? 'valid' : 'invalid'}</div>
|
|
62
|
+
<div data-testid="dirty">{dirty ? 'dirty' : 'clean'}</div>
|
|
63
|
+
<div data-testid="name-error">{errors.name || ''}</div>
|
|
64
|
+
<button onClick={() => model.setName('John')}>Set Name</button>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('useModel', () => {
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
vi.useFakeTimers();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
vi.useRealTimers();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should return state and validation', () => {
|
|
79
|
+
render(<FormComponent />);
|
|
80
|
+
|
|
81
|
+
expect(screen.getByTestId('name').textContent).toBe('');
|
|
82
|
+
expect(screen.getByTestId('valid').textContent).toBe('invalid');
|
|
83
|
+
expect(screen.getByTestId('name-error').textContent).toBe('Name is required');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should track dirty state', () => {
|
|
87
|
+
render(<FormComponent />);
|
|
88
|
+
|
|
89
|
+
expect(screen.getByTestId('dirty').textContent).toBe('clean');
|
|
90
|
+
|
|
91
|
+
act(() => {
|
|
92
|
+
screen.getByRole('button').click();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(screen.getByTestId('dirty').textContent).toBe('dirty');
|
|
96
|
+
expect(screen.getByTestId('name').textContent).toBe('John');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should update validation on state change', () => {
|
|
100
|
+
render(<FormComponent />);
|
|
101
|
+
|
|
102
|
+
expect(screen.getByTestId('name-error').textContent).toBe('Name is required');
|
|
103
|
+
|
|
104
|
+
act(() => {
|
|
105
|
+
screen.getByRole('button').click();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(screen.getByTestId('name-error').textContent).toBe('');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should dispose on unmount', () => {
|
|
112
|
+
let disposed = false;
|
|
113
|
+
class TrackingModel extends Model<FormState> {
|
|
114
|
+
protected onDispose() {
|
|
115
|
+
disposed = true;
|
|
116
|
+
}
|
|
117
|
+
setName(name: string) {
|
|
118
|
+
this.set({ name });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function Tracked() {
|
|
123
|
+
useModel(() => new TrackingModel({ name: '', email: '', age: 0 }));
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const { unmount } = render(<Tracked />);
|
|
128
|
+
expect(disposed).toBe(false);
|
|
129
|
+
|
|
130
|
+
unmount();
|
|
131
|
+
vi.runAllTimers();
|
|
132
|
+
expect(disposed).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('init lifecycle', () => {
|
|
136
|
+
it('should auto-call init on mount', () => {
|
|
137
|
+
let initCalled = false;
|
|
138
|
+
class InitModel extends Model<FormState> {
|
|
139
|
+
protected onInit() {
|
|
140
|
+
initCalled = true;
|
|
141
|
+
}
|
|
142
|
+
setName(name: string) { this.set({ name }); }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function Comp() {
|
|
146
|
+
useModel(() => new InitModel({ name: '', email: '', age: 0 }));
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
render(<Comp />);
|
|
151
|
+
expect(initCalled).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should call init only once across re-renders', () => {
|
|
155
|
+
let initCount = 0;
|
|
156
|
+
class InitModel extends Model<FormState> {
|
|
157
|
+
protected onInit() {
|
|
158
|
+
initCount++;
|
|
159
|
+
}
|
|
160
|
+
setName(name: string) { this.set({ name }); }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function Comp() {
|
|
164
|
+
const { state, model } = useModel(() => new InitModel({ name: '', email: '', age: 0 }));
|
|
165
|
+
return (
|
|
166
|
+
<div>
|
|
167
|
+
<div data-testid="name">{state.name}</div>
|
|
168
|
+
<button onClick={() => model.setName('test')}>Set</button>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
render(<Comp />);
|
|
174
|
+
expect(initCount).toBe(1);
|
|
175
|
+
|
|
176
|
+
act(() => {
|
|
177
|
+
screen.getByRole('button').click();
|
|
178
|
+
});
|
|
179
|
+
expect(initCount).toBe(1);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('useModelRef', () => {
|
|
185
|
+
beforeEach(() => {
|
|
186
|
+
vi.useFakeTimers();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
afterEach(() => {
|
|
190
|
+
vi.useRealTimers();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should return the model instance', () => {
|
|
194
|
+
let capturedModel: PublicFormModel | null = null;
|
|
195
|
+
function Comp() {
|
|
196
|
+
capturedModel = useModelRef(() => new PublicFormModel({ name: 'John', email: 'j@test.com', age: 30 }));
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
render(<Comp />);
|
|
201
|
+
expect(capturedModel).toBeInstanceOf(PublicFormModel);
|
|
202
|
+
expect(capturedModel!.state.name).toBe('John');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should auto-call init on mount', () => {
|
|
206
|
+
let initCalled = false;
|
|
207
|
+
class InitModel extends Model<FormState> {
|
|
208
|
+
protected onInit() { initCalled = true; }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function Comp() {
|
|
212
|
+
useModelRef(() => new InitModel({ name: '', email: '', age: 0 }));
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
render(<Comp />);
|
|
217
|
+
expect(initCalled).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should dispose on unmount', () => {
|
|
221
|
+
let disposed = false;
|
|
222
|
+
class TrackingModel extends Model<FormState> {
|
|
223
|
+
protected onDispose() { disposed = true; }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function Comp() {
|
|
227
|
+
useModelRef(() => new TrackingModel({ name: '', email: '', age: 0 }));
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const { unmount } = render(<Comp />);
|
|
232
|
+
expect(disposed).toBe(false);
|
|
233
|
+
|
|
234
|
+
unmount();
|
|
235
|
+
vi.runAllTimers();
|
|
236
|
+
expect(disposed).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should not re-render parent when model state changes', () => {
|
|
240
|
+
let parentRenderCount = 0;
|
|
241
|
+
let capturedModel: PublicFormModel | null = null;
|
|
242
|
+
|
|
243
|
+
function Parent() {
|
|
244
|
+
parentRenderCount++;
|
|
245
|
+
capturedModel = useModelRef(() => new PublicFormModel({ name: '', email: 'test@test.com', age: 25 }));
|
|
246
|
+
return <div data-testid="parent">rendered</div>;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
render(<Parent />);
|
|
250
|
+
const rendersAfterMount = parentRenderCount;
|
|
251
|
+
|
|
252
|
+
act(() => {
|
|
253
|
+
capturedModel!.set({ name: 'Jane' });
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Parent should NOT have re-rendered
|
|
257
|
+
expect(parentRenderCount).toBe(rendersAfterMount);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('should return stable model across re-renders', () => {
|
|
261
|
+
const models: PublicFormModel[] = [];
|
|
262
|
+
|
|
263
|
+
function Comp({ label }: { label: string }) {
|
|
264
|
+
const model = useModelRef(() => new PublicFormModel({ name: '', email: '', age: 0 }));
|
|
265
|
+
models.push(model);
|
|
266
|
+
return <div>{label}</div>;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const { rerender } = render(<Comp label="a" />);
|
|
270
|
+
rerender(<Comp label="b" />);
|
|
271
|
+
rerender(<Comp label="c" />);
|
|
272
|
+
|
|
273
|
+
expect(models[0]).toBe(models[1]);
|
|
274
|
+
expect(models[1]).toBe(models[2]);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('works with useField for per-field isolation', () => {
|
|
278
|
+
let parentRenderCount = 0;
|
|
279
|
+
let fieldRenderCount = 0;
|
|
280
|
+
|
|
281
|
+
function NameField({ model }: { model: PublicFormModel }) {
|
|
282
|
+
fieldRenderCount++;
|
|
283
|
+
const { value, set } = useField(model, 'name');
|
|
284
|
+
return (
|
|
285
|
+
<div>
|
|
286
|
+
<div data-testid="field-value">{value}</div>
|
|
287
|
+
<button data-testid="set-name" onClick={() => set('Jane')}>Set</button>
|
|
288
|
+
</div>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function Parent() {
|
|
293
|
+
parentRenderCount++;
|
|
294
|
+
const model = useModelRef(() => new PublicFormModel({ name: '', email: 'test@test.com', age: 25 }));
|
|
295
|
+
return <NameField model={model} />;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
render(<Parent />);
|
|
299
|
+
const parentRendersAfterMount = parentRenderCount;
|
|
300
|
+
const fieldRendersAfterMount = fieldRenderCount;
|
|
301
|
+
|
|
302
|
+
act(() => {
|
|
303
|
+
screen.getByTestId('set-name').click();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Field should have re-rendered
|
|
307
|
+
expect(fieldRenderCount).toBeGreaterThan(fieldRendersAfterMount);
|
|
308
|
+
expect(screen.getByTestId('field-value').textContent).toBe('Jane');
|
|
309
|
+
|
|
310
|
+
// Parent should NOT have re-rendered
|
|
311
|
+
expect(parentRenderCount).toBe(parentRendersAfterMount);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe('useField', () => {
|
|
316
|
+
function FieldComponent({ model }: { model: PublicFormModel }) {
|
|
317
|
+
const { value, error, set } = useField(model, 'name');
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<div>
|
|
321
|
+
<div data-testid="value">{value}</div>
|
|
322
|
+
<div data-testid="error">{error || ''}</div>
|
|
323
|
+
<button onClick={() => set('Jane')}>Set</button>
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
it('should return field value and error', () => {
|
|
329
|
+
const model = new PublicFormModel({ name: '', email: 'test@test.com', age: 25 });
|
|
330
|
+
render(<FieldComponent model={model} />);
|
|
331
|
+
|
|
332
|
+
expect(screen.getByTestId('value').textContent).toBe('');
|
|
333
|
+
expect(screen.getByTestId('error').textContent).toBe('Name is required');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('should update field value', () => {
|
|
337
|
+
const model = new PublicFormModel({ name: '', email: 'test@test.com', age: 25 });
|
|
338
|
+
render(<FieldComponent model={model} />);
|
|
339
|
+
|
|
340
|
+
act(() => {
|
|
341
|
+
screen.getByRole('button').click();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
expect(screen.getByTestId('value').textContent).toBe('Jane');
|
|
345
|
+
expect(screen.getByTestId('error').textContent).toBe('');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('should only re-render when field changes', () => {
|
|
349
|
+
const model = new PublicFormModel({ name: 'John', email: 'test@test.com', age: 25 });
|
|
350
|
+
let renderCount = 0;
|
|
351
|
+
|
|
352
|
+
function TrackedField({ model }: { model: PublicFormModel }) {
|
|
353
|
+
renderCount++;
|
|
354
|
+
const { value } = useField(model, 'name');
|
|
355
|
+
return <div data-testid="value">{value}</div>;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
render(<TrackedField model={model} />);
|
|
359
|
+
expect(renderCount).toBe(1);
|
|
360
|
+
|
|
361
|
+
// Change a different field - should not re-render
|
|
362
|
+
act(() => {
|
|
363
|
+
model.set({ email: 'new@test.com' });
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// renderCount might still be 1 or could be 2 depending on React
|
|
367
|
+
// The important thing is that the value hasn't changed
|
|
368
|
+
expect(screen.getByTestId('value').textContent).toBe('John');
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe('type-level: useField rejects invalid field names', () => {
|
|
373
|
+
it('rejects invalid field key via @ts-expect-error', () => {
|
|
374
|
+
const model = new PublicFormModel({ name: '', email: '', age: 0 });
|
|
375
|
+
|
|
376
|
+
// Compile-time only — runtime call would fail, we just check the type error
|
|
377
|
+
const _useField = (() => ({})) as unknown as typeof useField;
|
|
378
|
+
|
|
379
|
+
// @ts-expect-error - 'invalidField' is not a key of FormState
|
|
380
|
+
_useField(model, 'invalidField');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('accepts valid field key without error', () => {
|
|
384
|
+
const model = new PublicFormModel({ name: '', email: '', age: 0 });
|
|
385
|
+
const _useField = (() => ({})) as unknown as typeof useField;
|
|
386
|
+
|
|
387
|
+
// These should NOT produce type errors — Model<S> has concrete S-typed
|
|
388
|
+
// properties (state, committed, errors) that anchor inference of S,
|
|
389
|
+
// preventing TypeScript from widening S to satisfy an invalid key.
|
|
390
|
+
_useField(model, 'name');
|
|
391
|
+
_useField(model, 'email');
|
|
392
|
+
_useField(model, 'age');
|
|
393
|
+
});
|
|
394
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { useRef, useEffect, useSyncExternalStore, useCallback } from 'react';
|
|
2
|
+
import type { Model } from '../Model';
|
|
3
|
+
import type { ValidationErrors } from '../types';
|
|
4
|
+
import type { StateOf } from './types';
|
|
5
|
+
import { isInitializable } from './guards';
|
|
6
|
+
|
|
7
|
+
/** Return type of `useModel`, providing state, validation, and model access. */
|
|
8
|
+
export interface ModelHandle<S extends object, M extends Model<S>> {
|
|
9
|
+
state: S;
|
|
10
|
+
errors: ValidationErrors<S>;
|
|
11
|
+
valid: boolean;
|
|
12
|
+
dirty: boolean;
|
|
13
|
+
model: M;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Bind to a component-scoped Model with validation and dirty state exposed.
|
|
18
|
+
*/
|
|
19
|
+
export function useModel<M extends Model<any>>(
|
|
20
|
+
factory: () => M
|
|
21
|
+
): ModelHandle<StateOf<M>, M> {
|
|
22
|
+
const modelRef = useRef<M | null>(null);
|
|
23
|
+
const mountedRef = useRef(false);
|
|
24
|
+
|
|
25
|
+
if (!modelRef.current || modelRef.current.disposed) {
|
|
26
|
+
modelRef.current = factory();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const modelSubscribe = useCallback(
|
|
30
|
+
(onStoreChange: () => void) => modelRef.current!.subscribe(onStoreChange),
|
|
31
|
+
[]
|
|
32
|
+
);
|
|
33
|
+
const modelSnapshot = useCallback(
|
|
34
|
+
() => modelRef.current!.state,
|
|
35
|
+
[]
|
|
36
|
+
);
|
|
37
|
+
useSyncExternalStore(modelSubscribe, modelSnapshot, modelSnapshot);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
mountedRef.current = true;
|
|
41
|
+
if (isInitializable(modelRef.current)) {
|
|
42
|
+
modelRef.current.init();
|
|
43
|
+
}
|
|
44
|
+
return () => {
|
|
45
|
+
mountedRef.current = false;
|
|
46
|
+
setTimeout(() => {
|
|
47
|
+
if (!mountedRef.current) {
|
|
48
|
+
modelRef.current?.dispose();
|
|
49
|
+
}
|
|
50
|
+
}, 0);
|
|
51
|
+
};
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
const model = modelRef.current;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
state: model.state,
|
|
58
|
+
errors: model.errors,
|
|
59
|
+
valid: model.valid,
|
|
60
|
+
dirty: model.dirty,
|
|
61
|
+
model,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a component-scoped Model with lifecycle management (init + dispose)
|
|
67
|
+
* but NO state subscription. The parent component never re-renders from
|
|
68
|
+
* model state changes.
|
|
69
|
+
*
|
|
70
|
+
* Designed for the per-field isolation pattern: parent creates the model
|
|
71
|
+
* via `useModelRef`, children subscribe to individual fields via `useField`.
|
|
72
|
+
*/
|
|
73
|
+
export function useModelRef<M extends Model<any>>(factory: () => M): M {
|
|
74
|
+
const modelRef = useRef<M | null>(null);
|
|
75
|
+
const mountedRef = useRef(false);
|
|
76
|
+
|
|
77
|
+
if (!modelRef.current || modelRef.current.disposed) {
|
|
78
|
+
modelRef.current = factory();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
mountedRef.current = true;
|
|
83
|
+
if (isInitializable(modelRef.current)) {
|
|
84
|
+
modelRef.current.init();
|
|
85
|
+
}
|
|
86
|
+
return () => {
|
|
87
|
+
mountedRef.current = false;
|
|
88
|
+
setTimeout(() => {
|
|
89
|
+
if (!mountedRef.current) {
|
|
90
|
+
modelRef.current?.dispose();
|
|
91
|
+
}
|
|
92
|
+
}, 0);
|
|
93
|
+
};
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
return modelRef.current;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Return type of `useField`, providing a single field's value, error, and setter. */
|
|
100
|
+
export interface FieldHandle<V> {
|
|
101
|
+
value: V;
|
|
102
|
+
error: string | undefined;
|
|
103
|
+
set: (value: V) => void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Bind to a single Model field with surgical re-renders.
|
|
108
|
+
*/
|
|
109
|
+
export function useField<S extends object, K extends keyof S>(
|
|
110
|
+
model: Model<S>,
|
|
111
|
+
field: K
|
|
112
|
+
): FieldHandle<S[K]> {
|
|
113
|
+
// Track the field value and error for comparison
|
|
114
|
+
const getSnapshot = useCallback(() => {
|
|
115
|
+
return {
|
|
116
|
+
value: model.state[field],
|
|
117
|
+
error: model.errors[field],
|
|
118
|
+
};
|
|
119
|
+
}, [model, field]);
|
|
120
|
+
|
|
121
|
+
// Use object comparison for subscription
|
|
122
|
+
const cachedRef = useRef(getSnapshot());
|
|
123
|
+
|
|
124
|
+
const subscribe = useCallback(
|
|
125
|
+
(onStoreChange: () => void) => {
|
|
126
|
+
return model.subscribe(() => {
|
|
127
|
+
const next = getSnapshot();
|
|
128
|
+
const current = cachedRef.current;
|
|
129
|
+
|
|
130
|
+
// Only trigger re-render if field value or error changed
|
|
131
|
+
if (next.value !== current.value || next.error !== current.error) {
|
|
132
|
+
cachedRef.current = next;
|
|
133
|
+
onStoreChange();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
[model, getSnapshot]
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const snapshot = useSyncExternalStore(
|
|
141
|
+
subscribe,
|
|
142
|
+
() => cachedRef.current,
|
|
143
|
+
() => cachedRef.current
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const set = useCallback(
|
|
147
|
+
(value: S[K]) => {
|
|
148
|
+
// Access the protected set method through type assertion
|
|
149
|
+
// The Model subclass should expose a setter method
|
|
150
|
+
const partial: Partial<S> = { [field]: value } as unknown as Partial<S>;
|
|
151
|
+
(model as unknown as { set: (partial: Partial<S>) => void }).set(partial);
|
|
152
|
+
},
|
|
153
|
+
[model, field]
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
value: snapshot.value,
|
|
158
|
+
error: snapshot.error,
|
|
159
|
+
set,
|
|
160
|
+
};
|
|
161
|
+
}
|