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,692 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { render, screen, act } from '@testing-library/react';
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
import { vi } from 'vitest';
|
|
7
|
+
import { ViewModel } from '../ViewModel';
|
|
8
|
+
import { Controller } from '../Controller';
|
|
9
|
+
import { Trackable } from '../Trackable';
|
|
10
|
+
import { useLocal } from './use-local';
|
|
11
|
+
|
|
12
|
+
interface CounterState {
|
|
13
|
+
count: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class CounterVM extends ViewModel<CounterState> {
|
|
17
|
+
static instanceCount = 0;
|
|
18
|
+
|
|
19
|
+
constructor(initial = 0) {
|
|
20
|
+
super({ count: initial });
|
|
21
|
+
CounterVM.instanceCount++;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
increment() {
|
|
25
|
+
this.set((prev) => ({ count: prev.count + 1 }));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
protected onDispose() {
|
|
29
|
+
CounterVM.instanceCount--;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function Counter({ initial = 0 }: { initial?: number }) {
|
|
34
|
+
const [state, vm] = useLocal(CounterVM, initial);
|
|
35
|
+
return (
|
|
36
|
+
<div>
|
|
37
|
+
<div data-testid="count">{state.count}</div>
|
|
38
|
+
<button onClick={() => vm.increment()}>Increment</button>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('useLocal', () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
vi.useFakeTimers();
|
|
46
|
+
CounterVM.instanceCount = 0;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
vi.useRealTimers();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('with Subscribable (class-based)', () => {
|
|
54
|
+
it('should return state and vm tuple', () => {
|
|
55
|
+
render(<Counter initial={5} />);
|
|
56
|
+
expect(screen.getByTestId('count').textContent).toBe('5');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should create separate instances for each component', () => {
|
|
60
|
+
function App() {
|
|
61
|
+
return (
|
|
62
|
+
<>
|
|
63
|
+
<Counter initial={0} />
|
|
64
|
+
<Counter initial={10} />
|
|
65
|
+
</>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
render(<App />);
|
|
70
|
+
const counts = screen.getAllByTestId('count');
|
|
71
|
+
expect(counts).toHaveLength(2);
|
|
72
|
+
expect(counts[0].textContent).toBe('0');
|
|
73
|
+
expect(counts[1].textContent).toBe('10');
|
|
74
|
+
expect(CounterVM.instanceCount).toBe(2);
|
|
75
|
+
|
|
76
|
+
act(() => {
|
|
77
|
+
screen.getAllByRole('button')[0].click();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(counts[0].textContent).toBe('1');
|
|
81
|
+
expect(counts[1].textContent).toBe('10');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should dispose on unmount', () => {
|
|
85
|
+
const { unmount } = render(<Counter />);
|
|
86
|
+
expect(CounterVM.instanceCount).toBe(1);
|
|
87
|
+
|
|
88
|
+
unmount();
|
|
89
|
+
vi.runAllTimers();
|
|
90
|
+
|
|
91
|
+
expect(CounterVM.instanceCount).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should maintain same instance across re-renders', () => {
|
|
95
|
+
const { rerender } = render(<Counter initial={0} />);
|
|
96
|
+
expect(CounterVM.instanceCount).toBe(1);
|
|
97
|
+
|
|
98
|
+
act(() => {
|
|
99
|
+
screen.getByRole('button').click();
|
|
100
|
+
});
|
|
101
|
+
expect(screen.getByTestId('count').textContent).toBe('1');
|
|
102
|
+
|
|
103
|
+
rerender(<Counter initial={0} />);
|
|
104
|
+
|
|
105
|
+
expect(CounterVM.instanceCount).toBe(1);
|
|
106
|
+
expect(screen.getByTestId('count').textContent).toBe('1');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('with Subscribable (factory-based)', () => {
|
|
111
|
+
function FactoryCounter({ initial = 0 }: { initial?: number }) {
|
|
112
|
+
const [state, vm] = useLocal(() => new CounterVM(initial));
|
|
113
|
+
return (
|
|
114
|
+
<div>
|
|
115
|
+
<div data-testid="factory-count">{state.count}</div>
|
|
116
|
+
<button onClick={() => vm.increment()}>Increment</button>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
it('should return state and vm tuple', () => {
|
|
122
|
+
render(<FactoryCounter initial={7} />);
|
|
123
|
+
expect(screen.getByTestId('factory-count').textContent).toBe('7');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should dispose on unmount', () => {
|
|
127
|
+
const { unmount } = render(<FactoryCounter />);
|
|
128
|
+
expect(CounterVM.instanceCount).toBe(1);
|
|
129
|
+
|
|
130
|
+
unmount();
|
|
131
|
+
vi.runAllTimers();
|
|
132
|
+
|
|
133
|
+
expect(CounterVM.instanceCount).toBe(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should maintain same instance across re-renders', () => {
|
|
137
|
+
const { rerender } = render(<FactoryCounter initial={0} />);
|
|
138
|
+
expect(CounterVM.instanceCount).toBe(1);
|
|
139
|
+
|
|
140
|
+
act(() => {
|
|
141
|
+
screen.getByRole('button').click();
|
|
142
|
+
});
|
|
143
|
+
expect(screen.getByTestId('factory-count').textContent).toBe('1');
|
|
144
|
+
|
|
145
|
+
rerender(<FactoryCounter initial={0} />);
|
|
146
|
+
expect(CounterVM.instanceCount).toBe(1);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('with Disposable-only (class-based)', () => {
|
|
151
|
+
class CountController extends Controller {
|
|
152
|
+
private value = 0;
|
|
153
|
+
onDisposeCalled = false;
|
|
154
|
+
|
|
155
|
+
increment(): number {
|
|
156
|
+
this.value++;
|
|
157
|
+
return this.value;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
getValue(): number {
|
|
161
|
+
return this.value;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
protected onDispose() {
|
|
165
|
+
this.onDisposeCalled = true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function ControllerComponent() {
|
|
170
|
+
const controller = useLocal(CountController);
|
|
171
|
+
const [count, setCount] = useState(controller.getValue());
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<div>
|
|
175
|
+
<div data-testid="ctrl-count">{count}</div>
|
|
176
|
+
<button onClick={() => setCount(controller.increment())}>Increment</button>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
it('should return controller instance directly', () => {
|
|
182
|
+
render(<ControllerComponent />);
|
|
183
|
+
expect(screen.getByTestId('ctrl-count').textContent).toBe('0');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should call controller methods', () => {
|
|
187
|
+
render(<ControllerComponent />);
|
|
188
|
+
|
|
189
|
+
act(() => {
|
|
190
|
+
screen.getByRole('button').click();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(screen.getByTestId('ctrl-count').textContent).toBe('1');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should dispose on unmount', () => {
|
|
197
|
+
let controller: CountController;
|
|
198
|
+
|
|
199
|
+
function Wrapper() {
|
|
200
|
+
controller = useLocal(CountController);
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const { unmount } = render(<Wrapper />);
|
|
205
|
+
expect(controller!.disposed).toBe(false);
|
|
206
|
+
|
|
207
|
+
unmount();
|
|
208
|
+
vi.runAllTimers();
|
|
209
|
+
expect(controller!.disposed).toBe(true);
|
|
210
|
+
expect(controller!.onDisposeCalled).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('with Disposable-only (factory-based)', () => {
|
|
215
|
+
class ConfigurableController extends Controller {
|
|
216
|
+
readonly config: string;
|
|
217
|
+
constructor(config: string) {
|
|
218
|
+
super();
|
|
219
|
+
this.config = config;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function FactoryControllerComponent({ config }: { config: string }) {
|
|
224
|
+
const controller = useLocal(() => new ConfigurableController(config));
|
|
225
|
+
return <div data-testid="config">{controller.config}</div>;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
it('should support factory pattern for controllers', () => {
|
|
229
|
+
render(<FactoryControllerComponent config="test-config" />);
|
|
230
|
+
expect(screen.getByTestId('config').textContent).toBe('test-config');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should dispose on unmount', () => {
|
|
234
|
+
let controller: ConfigurableController;
|
|
235
|
+
|
|
236
|
+
function Wrapper() {
|
|
237
|
+
controller = useLocal(() => new ConfigurableController('test'));
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const { unmount } = render(<Wrapper />);
|
|
242
|
+
expect(controller!.disposed).toBe(false);
|
|
243
|
+
|
|
244
|
+
unmount();
|
|
245
|
+
vi.runAllTimers();
|
|
246
|
+
expect(controller!.disposed).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('with deps', () => {
|
|
251
|
+
interface UserState {
|
|
252
|
+
userId: string;
|
|
253
|
+
data: string | null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
class UserVM extends ViewModel<UserState> {
|
|
257
|
+
static instanceCount = 0;
|
|
258
|
+
initCount = 0;
|
|
259
|
+
|
|
260
|
+
constructor(state: UserState) {
|
|
261
|
+
super(state);
|
|
262
|
+
UserVM.instanceCount++;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
protected onInit() {
|
|
266
|
+
this.initCount++;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
protected onDispose() {
|
|
270
|
+
UserVM.instanceCount--;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
setData(data: string) {
|
|
274
|
+
this.set({ data });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
beforeEach(() => {
|
|
279
|
+
UserVM.instanceCount = 0;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('disposes old and creates new on dep change (class + deps)', () => {
|
|
283
|
+
function UserDetail({ userId }: { userId: string }) {
|
|
284
|
+
const [state] = useLocal(UserVM, { userId, data: null }, [userId]);
|
|
285
|
+
return <div data-testid="userId">{state.userId}</div>;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const { rerender } = render(<UserDetail userId="1" />);
|
|
289
|
+
expect(UserVM.instanceCount).toBe(1);
|
|
290
|
+
expect(screen.getByTestId('userId').textContent).toBe('1');
|
|
291
|
+
|
|
292
|
+
rerender(<UserDetail userId="2" />);
|
|
293
|
+
vi.runAllTimers();
|
|
294
|
+
|
|
295
|
+
expect(UserVM.instanceCount).toBe(1);
|
|
296
|
+
expect(screen.getByTestId('userId').textContent).toBe('2');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('keeps same instance when deps are stable', () => {
|
|
300
|
+
let latestVm: UserVM | null = null;
|
|
301
|
+
|
|
302
|
+
function UserDetail({ userId }: { userId: string }) {
|
|
303
|
+
const [state, vm] = useLocal(UserVM, { userId, data: null }, [userId]);
|
|
304
|
+
latestVm = vm;
|
|
305
|
+
return <div data-testid="userId">{state.userId}</div>;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const { rerender } = render(<UserDetail userId="1" />);
|
|
309
|
+
const vm1 = latestVm!;
|
|
310
|
+
expect(UserVM.instanceCount).toBe(1);
|
|
311
|
+
|
|
312
|
+
rerender(<UserDetail userId="1" />);
|
|
313
|
+
expect(UserVM.instanceCount).toBe(1);
|
|
314
|
+
expect(latestVm).toBe(vm1);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('disposes old and creates new on dep change (factory + deps)', () => {
|
|
318
|
+
function FactoryUserDetail({ userId }: { userId: string }) {
|
|
319
|
+
const [state] = useLocal(() => new UserVM({ userId, data: null }), [userId]);
|
|
320
|
+
return <div data-testid="userId">{state.userId}</div>;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const { rerender } = render(<FactoryUserDetail userId="1" />);
|
|
324
|
+
expect(UserVM.instanceCount).toBe(1);
|
|
325
|
+
expect(screen.getByTestId('userId').textContent).toBe('1');
|
|
326
|
+
|
|
327
|
+
rerender(<FactoryUserDetail userId="2" />);
|
|
328
|
+
vi.runAllTimers();
|
|
329
|
+
|
|
330
|
+
expect(UserVM.instanceCount).toBe(1);
|
|
331
|
+
expect(screen.getByTestId('userId').textContent).toBe('2');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('calls init on mount and after each dep change', () => {
|
|
335
|
+
let latestVm: UserVM | null = null;
|
|
336
|
+
|
|
337
|
+
function UserDetail({ userId }: { userId: string }) {
|
|
338
|
+
const [, vm] = useLocal(UserVM, { userId, data: null }, [userId]);
|
|
339
|
+
latestVm = vm;
|
|
340
|
+
return <div>{userId}</div>;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
render(<UserDetail userId="1" />);
|
|
344
|
+
const vm1 = latestVm!;
|
|
345
|
+
expect(vm1.initCount).toBe(1);
|
|
346
|
+
|
|
347
|
+
// Trigger dep change — re-render triggers effect re-run but we need act
|
|
348
|
+
act(() => {
|
|
349
|
+
render(<UserDetail userId="2" />);
|
|
350
|
+
});
|
|
351
|
+
const vm2 = latestVm!;
|
|
352
|
+
expect(vm2).not.toBe(vm1);
|
|
353
|
+
expect(vm2.initCount).toBe(1);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('multi deps triggers on either element changing', () => {
|
|
357
|
+
function Multi({ a, b }: { a: string; b: number }) {
|
|
358
|
+
const [state] = useLocal(UserVM, { userId: a, data: String(b) }, [a, b]);
|
|
359
|
+
return <div data-testid="data">{state.userId}-{state.data}</div>;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const { rerender } = render(<Multi a="x" b={1} />);
|
|
363
|
+
expect(UserVM.instanceCount).toBe(1);
|
|
364
|
+
|
|
365
|
+
rerender(<Multi a="x" b={2} />);
|
|
366
|
+
vi.runAllTimers();
|
|
367
|
+
expect(UserVM.instanceCount).toBe(1);
|
|
368
|
+
expect(screen.getByTestId('data').textContent).toBe('x-2');
|
|
369
|
+
|
|
370
|
+
rerender(<Multi a="y" b={2} />);
|
|
371
|
+
vi.runAllTimers();
|
|
372
|
+
expect(UserVM.instanceCount).toBe(1);
|
|
373
|
+
expect(screen.getByTestId('data').textContent).toBe('y-2');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('disposes on unmount with deps', () => {
|
|
377
|
+
function UserDetail({ userId }: { userId: string }) {
|
|
378
|
+
const [state] = useLocal(UserVM, { userId, data: null }, [userId]);
|
|
379
|
+
return <div>{state.userId}</div>;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const { unmount } = render(<UserDetail userId="1" />);
|
|
383
|
+
expect(UserVM.instanceCount).toBe(1);
|
|
384
|
+
|
|
385
|
+
unmount();
|
|
386
|
+
vi.runAllTimers();
|
|
387
|
+
expect(UserVM.instanceCount).toBe(0);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('aborts old disposeSignal on dep change', () => {
|
|
391
|
+
const signals: AbortSignal[] = [];
|
|
392
|
+
|
|
393
|
+
class SignalVM extends ViewModel<UserState> {
|
|
394
|
+
static instanceCount = 0;
|
|
395
|
+
|
|
396
|
+
constructor(state: UserState) {
|
|
397
|
+
super(state);
|
|
398
|
+
SignalVM.instanceCount++;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
protected onInit() {
|
|
402
|
+
signals.push(this.disposeSignal);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
protected onDispose() {
|
|
406
|
+
SignalVM.instanceCount--;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function Comp({ userId }: { userId: string }) {
|
|
411
|
+
const [state] = useLocal(SignalVM, { userId, data: null }, [userId]);
|
|
412
|
+
return <div>{state.userId}</div>;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const { rerender } = render(<Comp userId="1" />);
|
|
416
|
+
expect(signals).toHaveLength(1);
|
|
417
|
+
expect(signals[0].aborted).toBe(false);
|
|
418
|
+
|
|
419
|
+
rerender(<Comp userId="2" />);
|
|
420
|
+
expect(signals).toHaveLength(2);
|
|
421
|
+
expect(signals[0].aborted).toBe(true);
|
|
422
|
+
expect(signals[1].aborted).toBe(false);
|
|
423
|
+
|
|
424
|
+
SignalVM.instanceCount = 0;
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('rapid dep changes — only final instance alive', () => {
|
|
428
|
+
function UserDetail({ userId }: { userId: string }) {
|
|
429
|
+
const [state] = useLocal(UserVM, { userId, data: null }, [userId]);
|
|
430
|
+
return <div data-testid="userId">{state.userId}</div>;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const { rerender } = render(<UserDetail userId="1" />);
|
|
434
|
+
rerender(<UserDetail userId="2" />);
|
|
435
|
+
rerender(<UserDetail userId="3" />);
|
|
436
|
+
rerender(<UserDetail userId="4" />);
|
|
437
|
+
vi.runAllTimers();
|
|
438
|
+
|
|
439
|
+
expect(UserVM.instanceCount).toBe(1);
|
|
440
|
+
expect(screen.getByTestId('userId').textContent).toBe('4');
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('existing behavior unchanged without deps', () => {
|
|
444
|
+
render(<Counter initial={5} />);
|
|
445
|
+
expect(screen.getByTestId('count').textContent).toBe('5');
|
|
446
|
+
expect(CounterVM.instanceCount).toBe(1);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('factory + Disposable-only + deps recreates on change', () => {
|
|
450
|
+
class TimerCtrl extends Controller {
|
|
451
|
+
static instanceCount = 0;
|
|
452
|
+
readonly id: string;
|
|
453
|
+
|
|
454
|
+
constructor(id: string) {
|
|
455
|
+
super();
|
|
456
|
+
this.id = id;
|
|
457
|
+
TimerCtrl.instanceCount++;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
protected onDispose() {
|
|
461
|
+
TimerCtrl.instanceCount--;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function Comp({ roomId }: { roomId: string }) {
|
|
466
|
+
const ctrl = useLocal(() => new TimerCtrl(roomId), [roomId]);
|
|
467
|
+
return <div data-testid="roomId">{ctrl.id}</div>;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const { rerender } = render(<Comp roomId="a" />);
|
|
471
|
+
expect(TimerCtrl.instanceCount).toBe(1);
|
|
472
|
+
expect(screen.getByTestId('roomId').textContent).toBe('a');
|
|
473
|
+
|
|
474
|
+
rerender(<Comp roomId="b" />);
|
|
475
|
+
vi.runAllTimers();
|
|
476
|
+
expect(TimerCtrl.instanceCount).toBe(1);
|
|
477
|
+
expect(screen.getByTestId('roomId').textContent).toBe('b');
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
describe('init lifecycle', () => {
|
|
482
|
+
it('should auto-call init on mount for Subscribable', () => {
|
|
483
|
+
let initCalled = false;
|
|
484
|
+
class InitVM extends ViewModel<CounterState> {
|
|
485
|
+
constructor() {
|
|
486
|
+
super({ count: 0 });
|
|
487
|
+
}
|
|
488
|
+
protected onInit() {
|
|
489
|
+
initCalled = true;
|
|
490
|
+
}
|
|
491
|
+
increment() {
|
|
492
|
+
this.set((prev) => ({ count: prev.count + 1 }));
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function Comp() {
|
|
497
|
+
const [state] = useLocal(InitVM);
|
|
498
|
+
return <div data-testid="count">{state.count}</div>;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
render(<Comp />);
|
|
502
|
+
expect(initCalled).toBe(true);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('should call init only once across re-renders', () => {
|
|
506
|
+
let initCount = 0;
|
|
507
|
+
class InitVM extends ViewModel<CounterState> {
|
|
508
|
+
constructor() {
|
|
509
|
+
super({ count: 0 });
|
|
510
|
+
}
|
|
511
|
+
protected onInit() {
|
|
512
|
+
initCount++;
|
|
513
|
+
}
|
|
514
|
+
increment() {
|
|
515
|
+
this.set((prev) => ({ count: prev.count + 1 }));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function Comp() {
|
|
520
|
+
const [state, vm] = useLocal(InitVM);
|
|
521
|
+
return (
|
|
522
|
+
<div>
|
|
523
|
+
<div data-testid="count">{state.count}</div>
|
|
524
|
+
<button onClick={() => vm.increment()}>+</button>
|
|
525
|
+
</div>
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
render(<Comp />);
|
|
530
|
+
expect(initCount).toBe(1);
|
|
531
|
+
|
|
532
|
+
act(() => {
|
|
533
|
+
screen.getByRole('button').click();
|
|
534
|
+
});
|
|
535
|
+
expect(initCount).toBe(1);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('should auto-call init for Disposable-only classes', () => {
|
|
539
|
+
let initCalled = false;
|
|
540
|
+
class InitCtrl extends Controller {
|
|
541
|
+
protected onInit() {
|
|
542
|
+
initCalled = true;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function Comp() {
|
|
547
|
+
useLocal(InitCtrl);
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
render(<Comp />);
|
|
552
|
+
expect(initCalled).toBe(true);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('should call init before dispose on unmount cycle', () => {
|
|
556
|
+
const order: string[] = [];
|
|
557
|
+
class LifecycleVM extends ViewModel<CounterState> {
|
|
558
|
+
constructor() {
|
|
559
|
+
super({ count: 0 });
|
|
560
|
+
}
|
|
561
|
+
protected onInit() {
|
|
562
|
+
order.push('init');
|
|
563
|
+
}
|
|
564
|
+
protected onDispose() {
|
|
565
|
+
order.push('dispose');
|
|
566
|
+
}
|
|
567
|
+
increment() {
|
|
568
|
+
this.set((prev) => ({ count: prev.count + 1 }));
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function Comp() {
|
|
573
|
+
const [state] = useLocal(LifecycleVM);
|
|
574
|
+
return <div>{state.count}</div>;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const { unmount } = render(<Comp />);
|
|
578
|
+
expect(order).toEqual(['init']);
|
|
579
|
+
|
|
580
|
+
unmount();
|
|
581
|
+
vi.runAllTimers();
|
|
582
|
+
expect(order).toEqual(['init', 'dispose']);
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
describe('with subscribe-only (Trackable)', () => {
|
|
587
|
+
class TrackableCounter extends Trackable {
|
|
588
|
+
private _count = 0;
|
|
589
|
+
|
|
590
|
+
get count() { return this._count; }
|
|
591
|
+
|
|
592
|
+
increment() {
|
|
593
|
+
this._count++;
|
|
594
|
+
this.notify();
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function TrackableComponent() {
|
|
599
|
+
const counter = useLocal(TrackableCounter);
|
|
600
|
+
return (
|
|
601
|
+
<div>
|
|
602
|
+
<div data-testid="trackable-count">{counter.count}</div>
|
|
603
|
+
<button onClick={() => counter.increment()}>+</button>
|
|
604
|
+
</div>
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
it('should return instance directly (not tuple)', () => {
|
|
609
|
+
render(<TrackableComponent />);
|
|
610
|
+
expect(screen.getByTestId('trackable-count').textContent).toBe('0');
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('should trigger re-render when notify() fires', () => {
|
|
614
|
+
render(<TrackableComponent />);
|
|
615
|
+
expect(screen.getByTestId('trackable-count').textContent).toBe('0');
|
|
616
|
+
|
|
617
|
+
act(() => {
|
|
618
|
+
screen.getByRole('button').click();
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
expect(screen.getByTestId('trackable-count').textContent).toBe('1');
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('should dispose on unmount', () => {
|
|
625
|
+
let counter: TrackableCounter;
|
|
626
|
+
|
|
627
|
+
function Wrapper() {
|
|
628
|
+
counter = useLocal(TrackableCounter);
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const { unmount } = render(<Wrapper />);
|
|
633
|
+
expect(counter!.disposed).toBe(false);
|
|
634
|
+
|
|
635
|
+
unmount();
|
|
636
|
+
vi.runAllTimers();
|
|
637
|
+
expect(counter!.disposed).toBe(true);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('should support factory pattern', () => {
|
|
641
|
+
function FactoryComp() {
|
|
642
|
+
const counter = useLocal(() => new TrackableCounter());
|
|
643
|
+
return (
|
|
644
|
+
<div>
|
|
645
|
+
<div data-testid="factory-trackable">{counter.count}</div>
|
|
646
|
+
<button onClick={() => counter.increment()}>+</button>
|
|
647
|
+
</div>
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
render(<FactoryComp />);
|
|
652
|
+
expect(screen.getByTestId('factory-trackable').textContent).toBe('0');
|
|
653
|
+
|
|
654
|
+
act(() => {
|
|
655
|
+
screen.getByRole('button').click();
|
|
656
|
+
});
|
|
657
|
+
expect(screen.getByTestId('factory-trackable').textContent).toBe('1');
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('should support deps (recreates on change)', () => {
|
|
661
|
+
class ConfigTrackable extends Trackable {
|
|
662
|
+
static instanceCount = 0;
|
|
663
|
+
readonly id: string;
|
|
664
|
+
|
|
665
|
+
constructor(id: string) {
|
|
666
|
+
super();
|
|
667
|
+
this.id = id;
|
|
668
|
+
ConfigTrackable.instanceCount++;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
protected onDispose() {
|
|
672
|
+
ConfigTrackable.instanceCount--;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function Comp({ id }: { id: string }) {
|
|
677
|
+
const t = useLocal(() => new ConfigTrackable(id), [id]);
|
|
678
|
+
return <div data-testid="config-id">{t.id}</div>;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
ConfigTrackable.instanceCount = 0;
|
|
682
|
+
const { rerender } = render(<Comp id="a" />);
|
|
683
|
+
expect(ConfigTrackable.instanceCount).toBe(1);
|
|
684
|
+
expect(screen.getByTestId('config-id').textContent).toBe('a');
|
|
685
|
+
|
|
686
|
+
rerender(<Comp id="b" />);
|
|
687
|
+
vi.runAllTimers();
|
|
688
|
+
expect(ConfigTrackable.instanceCount).toBe(1);
|
|
689
|
+
expect(screen.getByTestId('config-id').textContent).toBe('b');
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
});
|