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,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*
|
|
4
|
+
* Rapid remount lifecycle tests — verifies that useLocal's dep-change
|
|
5
|
+
* dispose/recreate cycle properly aborts in-flight fetches via disposeSignal,
|
|
6
|
+
* isolates stale responses, and isolates errors across VM instances.
|
|
7
|
+
*
|
|
8
|
+
* Uses MSW to intercept real fetch() calls so AbortSignal propagation
|
|
9
|
+
* is tested end-to-end through the network stack.
|
|
10
|
+
*
|
|
11
|
+
* All VMs use the canonical pattern: sync onInit() calls this.load()
|
|
12
|
+
* fire-and-forget. load() is wrapped by _wrapMethods, so AbortErrors
|
|
13
|
+
* are silently swallowed and HttpErrors are captured by async tracking.
|
|
14
|
+
*/
|
|
15
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
16
|
+
import { http, HttpResponse, delay } from 'msw';
|
|
17
|
+
import { setupServer } from 'msw/node';
|
|
18
|
+
import { vi, afterAll, afterEach, beforeAll } from 'vitest';
|
|
19
|
+
import { ViewModel } from '../ViewModel';
|
|
20
|
+
import { HttpError } from '../errors';
|
|
21
|
+
import { Service } from '../Service';
|
|
22
|
+
import { useLocal } from './use-local';
|
|
23
|
+
|
|
24
|
+
// ── Test Service ────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
class UserService extends Service {
|
|
27
|
+
async getUser(id: string, signal?: AbortSignal): Promise<{ id: string; name: string }> {
|
|
28
|
+
const res = await fetch(`/api/users/${id}`, { signal });
|
|
29
|
+
if (!res.ok) throw new HttpError(res.status, res.statusText);
|
|
30
|
+
return res.json();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Test ViewModel ──────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
interface UserState {
|
|
37
|
+
userId: string | null;
|
|
38
|
+
data: { id: string; name: string } | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class UserViewModel extends ViewModel<UserState> {
|
|
42
|
+
private service = new UserService();
|
|
43
|
+
|
|
44
|
+
protected onInit() {
|
|
45
|
+
const { userId } = this.state;
|
|
46
|
+
if (!userId) return;
|
|
47
|
+
this.load();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async load() {
|
|
51
|
+
const { userId } = this.state;
|
|
52
|
+
if (!userId) return;
|
|
53
|
+
const data = await this.service.getUser(userId, this.disposeSignal);
|
|
54
|
+
this.set({ data });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Test Component ──────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function UserDetail({ userId }: { userId: string | null }) {
|
|
61
|
+
const [state, vm] = useLocal(UserViewModel, { userId, data: null }, [userId]);
|
|
62
|
+
return (
|
|
63
|
+
<div>
|
|
64
|
+
<span data-testid="userId">{state.userId ?? 'null'}</span>
|
|
65
|
+
<span data-testid="loading">{String(vm.async.load?.loading ?? false)}</span>
|
|
66
|
+
<span data-testid="error">{vm.async.load?.error ?? 'none'}</span>
|
|
67
|
+
<span data-testid="name">{state.data?.name ?? 'none'}</span>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── MSW Server ──────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const server = setupServer(
|
|
75
|
+
http.get('/api/users/:id', async ({ params }) => {
|
|
76
|
+
await delay(250);
|
|
77
|
+
return HttpResponse.json({ id: params.id, name: `User ${params.id}` });
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
|
82
|
+
afterEach(() => server.resetHandlers());
|
|
83
|
+
afterAll(() => server.close());
|
|
84
|
+
|
|
85
|
+
// Suppress DEV ghost warnings — we intentionally test dispose-during-fetch
|
|
86
|
+
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
89
|
+
});
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
consoleWarnSpy.mockRestore();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/** Let deferred dispose and microtasks settle */
|
|
97
|
+
function flush(): Promise<void> {
|
|
98
|
+
return new Promise((r) => setTimeout(r, 10));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ═════════════════════════════════════════════════════════════════════
|
|
102
|
+
// Tests
|
|
103
|
+
// ═════════════════════════════════════════════════════════════════════
|
|
104
|
+
|
|
105
|
+
describe('rapid remount lifecycle', () => {
|
|
106
|
+
describe('dep transitions', () => {
|
|
107
|
+
it('null → real userId: no fetch for null, fetch succeeds for real value', async () => {
|
|
108
|
+
const requestUrls: string[] = [];
|
|
109
|
+
server.events.on('request:start', ({ request }) => {
|
|
110
|
+
requestUrls.push(new URL(request.url).pathname);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const { rerender } = render(<UserDetail userId={null} />);
|
|
114
|
+
expect(screen.getByTestId('userId').textContent).toBe('null');
|
|
115
|
+
expect(screen.getByTestId('name').textContent).toBe('none');
|
|
116
|
+
|
|
117
|
+
// Transition to real userId
|
|
118
|
+
rerender(<UserDetail userId="42" />);
|
|
119
|
+
await flush();
|
|
120
|
+
|
|
121
|
+
await waitFor(() => {
|
|
122
|
+
expect(screen.getByTestId('name').textContent).toBe('User 42');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Verify no fetch was made for null
|
|
126
|
+
expect(requestUrls).not.toContain('/api/users/null');
|
|
127
|
+
expect(requestUrls).toContain('/api/users/42');
|
|
128
|
+
|
|
129
|
+
server.events.removeAllListeners();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('rapid 1→2→3→4: only final VM fetch completes, earlier signals aborted', async () => {
|
|
133
|
+
const signals = new Map<string, AbortSignal>();
|
|
134
|
+
|
|
135
|
+
class TrackedUserVM extends ViewModel<UserState> {
|
|
136
|
+
protected onInit() {
|
|
137
|
+
const { userId } = this.state;
|
|
138
|
+
if (!userId) return;
|
|
139
|
+
signals.set(userId, this.disposeSignal);
|
|
140
|
+
this.load();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async load() {
|
|
144
|
+
const { userId } = this.state;
|
|
145
|
+
if (!userId) return;
|
|
146
|
+
const res = await fetch(`/api/users/${userId}`, { signal: this.disposeSignal });
|
|
147
|
+
if (!res.ok) throw new HttpError(res.status, res.statusText);
|
|
148
|
+
const data = await res.json();
|
|
149
|
+
this.set({ data });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function Tracked({ userId }: { userId: string }) {
|
|
154
|
+
const [state] = useLocal(TrackedUserVM, { userId, data: null }, [userId]);
|
|
155
|
+
return (
|
|
156
|
+
<div>
|
|
157
|
+
<span data-testid="userId">{state.userId}</span>
|
|
158
|
+
<span data-testid="name">{state.data?.name ?? 'none'}</span>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const { rerender } = render(<Tracked userId="1" />);
|
|
164
|
+
rerender(<Tracked userId="2" />);
|
|
165
|
+
rerender(<Tracked userId="3" />);
|
|
166
|
+
rerender(<Tracked userId="4" />);
|
|
167
|
+
await flush();
|
|
168
|
+
|
|
169
|
+
await waitFor(() => {
|
|
170
|
+
expect(screen.getByTestId('name').textContent).toBe('User 4');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// VM 1's signal should be aborted (disposed during render-phase dep change)
|
|
174
|
+
expect(signals.get('1')?.aborted).toBe(true);
|
|
175
|
+
|
|
176
|
+
// VM 4's signal should NOT be aborted — it's the active instance
|
|
177
|
+
expect(signals.get('4')?.aborted).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('error on first VM does not bleed into second', async () => {
|
|
181
|
+
server.use(
|
|
182
|
+
http.get('/api/users/bad-user', async () => {
|
|
183
|
+
await delay(20);
|
|
184
|
+
return new HttpResponse(null, { status: 500, statusText: 'Internal Server Error' });
|
|
185
|
+
}),
|
|
186
|
+
http.get('/api/users/good-user', async () => {
|
|
187
|
+
await delay(20);
|
|
188
|
+
return HttpResponse.json({ id: 'good-user', name: 'Good User' });
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// VM that suppresses the re-thrown error from fire-and-forget load()
|
|
193
|
+
// (async tracking still captures it before re-throw)
|
|
194
|
+
class ErrorTestVM extends ViewModel<UserState> {
|
|
195
|
+
private service = new UserService();
|
|
196
|
+
|
|
197
|
+
protected onInit() {
|
|
198
|
+
const { userId } = this.state;
|
|
199
|
+
if (!userId) return;
|
|
200
|
+
// .catch suppresses unhandled rejection; async tracking already captured the error
|
|
201
|
+
this.load().catch(() => {});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async load() {
|
|
205
|
+
const { userId } = this.state;
|
|
206
|
+
if (!userId) return;
|
|
207
|
+
const data = await this.service.getUser(userId, this.disposeSignal);
|
|
208
|
+
this.set({ data });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function ErrorDetail({ userId }: { userId: string }) {
|
|
213
|
+
const [state, vm] = useLocal(ErrorTestVM, { userId, data: null }, [userId]);
|
|
214
|
+
return (
|
|
215
|
+
<div>
|
|
216
|
+
<span data-testid="userId">{state.userId}</span>
|
|
217
|
+
<span data-testid="loading">{String(vm.async.load?.loading ?? false)}</span>
|
|
218
|
+
<span data-testid="error">{vm.async.load?.error ?? 'none'}</span>
|
|
219
|
+
<span data-testid="name">{state.data?.name ?? 'none'}</span>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const { rerender } = render(<ErrorDetail userId="bad-user" />);
|
|
225
|
+
await flush();
|
|
226
|
+
|
|
227
|
+
// Wait for the error to be captured via async tracking
|
|
228
|
+
await waitFor(() => {
|
|
229
|
+
expect(screen.getByTestId('error').textContent).not.toBe('none');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Dep change to good user
|
|
233
|
+
rerender(<ErrorDetail userId="good-user" />);
|
|
234
|
+
await flush();
|
|
235
|
+
|
|
236
|
+
await waitFor(() => {
|
|
237
|
+
expect(screen.getByTestId('name').textContent).toBe('Good User');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// New VM should have clean async state — no error from the old VM
|
|
241
|
+
expect(screen.getByTestId('error').textContent).toBe('none');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('clean async state after rapid remount', async () => {
|
|
245
|
+
server.use(
|
|
246
|
+
http.get('/api/users/first', async () => {
|
|
247
|
+
await delay(200);
|
|
248
|
+
return new HttpResponse(null, { status: 500, statusText: 'Server Error' });
|
|
249
|
+
}),
|
|
250
|
+
http.get('/api/users/second', async () => {
|
|
251
|
+
await delay(20);
|
|
252
|
+
return HttpResponse.json({ id: 'second', name: 'Second User' });
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const { rerender } = render(<UserDetail userId="first" />);
|
|
257
|
+
await flush();
|
|
258
|
+
|
|
259
|
+
// Dep change before "first" has time to fail
|
|
260
|
+
rerender(<UserDetail userId="second" />);
|
|
261
|
+
await flush();
|
|
262
|
+
|
|
263
|
+
await waitFor(() => {
|
|
264
|
+
expect(screen.getByTestId('name').textContent).toBe('Second User');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// The new VM should have no error — first VM's fetch was aborted
|
|
268
|
+
expect(screen.getByTestId('error').textContent).toBe('none');
|
|
269
|
+
expect(screen.getByTestId('loading').textContent).toBe('false');
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe('unmount during fetch', () => {
|
|
274
|
+
it('unmount during active fetch aborts disposeSignal', async () => {
|
|
275
|
+
let capturedSignal: AbortSignal | null = null;
|
|
276
|
+
let setCalled = false;
|
|
277
|
+
|
|
278
|
+
class UnmountVM extends ViewModel<UserState> {
|
|
279
|
+
protected onInit() {
|
|
280
|
+
capturedSignal = this.disposeSignal;
|
|
281
|
+
this.load();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async load() {
|
|
285
|
+
const res = await fetch('/api/users/slow', { signal: this.disposeSignal });
|
|
286
|
+
const data = await res.json();
|
|
287
|
+
setCalled = true;
|
|
288
|
+
this.set({ data });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
server.use(
|
|
293
|
+
http.get('/api/users/slow', async () => {
|
|
294
|
+
await delay(300);
|
|
295
|
+
return HttpResponse.json({ id: 'slow', name: 'Slow User' });
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
function Comp() {
|
|
300
|
+
const [state] = useLocal(UnmountVM, { userId: 'slow', data: null });
|
|
301
|
+
return <span>{state.data?.name ?? 'none'}</span>;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const { unmount } = render(<Comp />);
|
|
305
|
+
await flush(); // let init fire
|
|
306
|
+
|
|
307
|
+
unmount();
|
|
308
|
+
await flush(); // let deferred dispose fire
|
|
309
|
+
|
|
310
|
+
expect(capturedSignal).not.toBeNull();
|
|
311
|
+
expect(capturedSignal!.aborted).toBe(true);
|
|
312
|
+
|
|
313
|
+
// Wait to ensure the fetch promise settled (aborted)
|
|
314
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
315
|
+
expect(setCalled).toBe(false);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe('stale response protection', () => {
|
|
320
|
+
it('slow VM1 cannot corrupt fast VM2', async () => {
|
|
321
|
+
server.use(
|
|
322
|
+
http.get('/api/users/slow', async () => {
|
|
323
|
+
await delay(500);
|
|
324
|
+
return HttpResponse.json({ id: 'slow', name: 'Slow User' });
|
|
325
|
+
}),
|
|
326
|
+
http.get('/api/users/fast', async () => {
|
|
327
|
+
await delay(20);
|
|
328
|
+
return HttpResponse.json({ id: 'fast', name: 'Fast User' });
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const { rerender } = render(<UserDetail userId="slow" />);
|
|
333
|
+
await flush();
|
|
334
|
+
|
|
335
|
+
// Dep change to fast before slow resolves
|
|
336
|
+
rerender(<UserDetail userId="fast" />);
|
|
337
|
+
await flush();
|
|
338
|
+
|
|
339
|
+
await waitFor(() => {
|
|
340
|
+
expect(screen.getByTestId('name').textContent).toBe('Fast User');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Wait past slow response time — should still show fast data
|
|
344
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
345
|
+
expect(screen.getByTestId('name').textContent).toBe('Fast User');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('defense in depth: set() is no-op on disposed VM even without disposeSignal', async () => {
|
|
349
|
+
class NoSignalVM extends ViewModel<UserState> {
|
|
350
|
+
protected onInit() {
|
|
351
|
+
const { userId } = this.state;
|
|
352
|
+
if (!userId) return;
|
|
353
|
+
this.load();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async load() {
|
|
357
|
+
const { userId } = this.state;
|
|
358
|
+
if (!userId) return;
|
|
359
|
+
// Intentionally NOT passing disposeSignal — testing second safety layer
|
|
360
|
+
const res = await fetch(`/api/users/${userId}`);
|
|
361
|
+
const data = await res.json();
|
|
362
|
+
this.set({ data });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function NoSignalComp({ userId }: { userId: string }) {
|
|
367
|
+
const [state] = useLocal(NoSignalVM, { userId, data: null }, [userId]);
|
|
368
|
+
return (
|
|
369
|
+
<div>
|
|
370
|
+
<span data-testid="name">{state.data?.name ?? 'none'}</span>
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
server.use(
|
|
376
|
+
http.get('/api/users/old', async () => {
|
|
377
|
+
await delay(300);
|
|
378
|
+
return HttpResponse.json({ id: 'old', name: 'Old User' });
|
|
379
|
+
}),
|
|
380
|
+
http.get('/api/users/new', async () => {
|
|
381
|
+
await delay(20);
|
|
382
|
+
return HttpResponse.json({ id: 'new', name: 'New User' });
|
|
383
|
+
}),
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
const { rerender } = render(<NoSignalComp userId="old" />);
|
|
387
|
+
await flush();
|
|
388
|
+
|
|
389
|
+
// Dep change before old resolves
|
|
390
|
+
rerender(<NoSignalComp userId="new" />);
|
|
391
|
+
await flush();
|
|
392
|
+
|
|
393
|
+
await waitFor(() => {
|
|
394
|
+
expect(screen.getByTestId('name').textContent).toBe('New User');
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// Wait for old response to arrive
|
|
398
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
399
|
+
|
|
400
|
+
// Old VM's set() was a no-op — still showing new data
|
|
401
|
+
expect(screen.getByTestId('name').textContent).toBe('New User');
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
describe('signal propagation', () => {
|
|
406
|
+
it('signal propagates through service layer', async () => {
|
|
407
|
+
let abortFired = false;
|
|
408
|
+
|
|
409
|
+
server.use(
|
|
410
|
+
http.get('/api/users/:id', async ({ request }) => {
|
|
411
|
+
await new Promise<void>((resolve, reject) => {
|
|
412
|
+
const timer = setTimeout(resolve, 500);
|
|
413
|
+
request.signal.addEventListener('abort', () => {
|
|
414
|
+
clearTimeout(timer);
|
|
415
|
+
abortFired = true;
|
|
416
|
+
reject(request.signal.reason);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
return HttpResponse.json({ id: 'x', name: 'X' });
|
|
420
|
+
}),
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const { rerender } = render(<UserDetail userId="first" />);
|
|
424
|
+
await flush(); // let init + fetch start
|
|
425
|
+
|
|
426
|
+
// Dep change — old VM disposed, fetch aborted
|
|
427
|
+
rerender(<UserDetail userId="second" />);
|
|
428
|
+
await flush();
|
|
429
|
+
|
|
430
|
+
// Give time for abort event to propagate
|
|
431
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
432
|
+
expect(abortFired).toBe(true);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('multiple parallel fetches all abort on dep change', async () => {
|
|
436
|
+
const abortedEndpoints = new Set<string>();
|
|
437
|
+
|
|
438
|
+
server.use(
|
|
439
|
+
http.get('/api/users/:id', async ({ request, params }) => {
|
|
440
|
+
await new Promise<void>((resolve, reject) => {
|
|
441
|
+
const timer = setTimeout(resolve, 500);
|
|
442
|
+
request.signal.addEventListener('abort', () => {
|
|
443
|
+
clearTimeout(timer);
|
|
444
|
+
abortedEndpoints.add(`users-${params.id}`);
|
|
445
|
+
reject(request.signal.reason);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
return HttpResponse.json({ id: params.id, name: `User ${params.id}` });
|
|
449
|
+
}),
|
|
450
|
+
http.get('/api/profiles/:id', async ({ request, params }) => {
|
|
451
|
+
await new Promise<void>((resolve, reject) => {
|
|
452
|
+
const timer = setTimeout(resolve, 500);
|
|
453
|
+
request.signal.addEventListener('abort', () => {
|
|
454
|
+
clearTimeout(timer);
|
|
455
|
+
abortedEndpoints.add(`profiles-${params.id}`);
|
|
456
|
+
reject(request.signal.reason);
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
return HttpResponse.json({ id: params.id, bio: 'Test bio' });
|
|
460
|
+
}),
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
interface MultiState {
|
|
464
|
+
userId: string;
|
|
465
|
+
data: any;
|
|
466
|
+
profile: any;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
class MultiVM extends ViewModel<MultiState> {
|
|
470
|
+
protected onInit() {
|
|
471
|
+
this.load();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async load() {
|
|
475
|
+
const { userId } = this.state;
|
|
476
|
+
const [data, profile] = await Promise.all([
|
|
477
|
+
fetch(`/api/users/${userId}`, { signal: this.disposeSignal }).then((r) => r.json()),
|
|
478
|
+
fetch(`/api/profiles/${userId}`, { signal: this.disposeSignal }).then((r) => r.json()),
|
|
479
|
+
]);
|
|
480
|
+
this.set({ data, profile });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function MultiComp({ userId }: { userId: string }) {
|
|
485
|
+
const [state] = useLocal(MultiVM, { userId, data: null, profile: null }, [userId]);
|
|
486
|
+
return <span data-testid="multi">{state.data?.name ?? 'none'}</span>;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const { rerender } = render(<MultiComp userId="abc" />);
|
|
490
|
+
await flush(); // let init + both fetches start
|
|
491
|
+
|
|
492
|
+
// Dep change — old VM disposed, both fetches aborted
|
|
493
|
+
rerender(<MultiComp userId="xyz" />);
|
|
494
|
+
await flush();
|
|
495
|
+
|
|
496
|
+
// Give time for abort events to propagate
|
|
497
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
498
|
+
|
|
499
|
+
expect(abortedEndpoints.has('users-abc')).toBe(true);
|
|
500
|
+
expect(abortedEndpoints.has('profiles-abc')).toBe(true);
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
});
|