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.
Files changed (139) hide show
  1. package/agent-config/bin/postinstall.mjs +5 -3
  2. package/agent-config/bin/setup.mjs +3 -4
  3. package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
  4. package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
  5. package/agent-config/lib/install-claude.mjs +10 -33
  6. package/dist/Model.cjs +9 -1
  7. package/dist/Model.cjs.map +1 -1
  8. package/dist/Model.d.ts +1 -1
  9. package/dist/Model.d.ts.map +1 -1
  10. package/dist/Model.js +9 -1
  11. package/dist/Model.js.map +1 -1
  12. package/dist/ViewModel.cjs +9 -1
  13. package/dist/ViewModel.cjs.map +1 -1
  14. package/dist/ViewModel.d.ts +1 -1
  15. package/dist/ViewModel.d.ts.map +1 -1
  16. package/dist/ViewModel.js +9 -1
  17. package/dist/ViewModel.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/mvc-kit.cjs +3 -0
  21. package/dist/mvc-kit.cjs.map +1 -1
  22. package/dist/mvc-kit.js +3 -0
  23. package/dist/mvc-kit.js.map +1 -1
  24. package/dist/produceDraft.cjs +105 -0
  25. package/dist/produceDraft.cjs.map +1 -0
  26. package/dist/produceDraft.d.ts +19 -0
  27. package/dist/produceDraft.d.ts.map +1 -0
  28. package/dist/produceDraft.js +105 -0
  29. package/dist/produceDraft.js.map +1 -0
  30. package/package.json +4 -2
  31. package/src/Channel.md +408 -0
  32. package/src/Channel.test.ts +957 -0
  33. package/src/Channel.ts +429 -0
  34. package/src/Collection.md +533 -0
  35. package/src/Collection.test.ts +1559 -0
  36. package/src/Collection.ts +653 -0
  37. package/src/Controller.md +306 -0
  38. package/src/Controller.test.ts +380 -0
  39. package/src/Controller.ts +90 -0
  40. package/src/EventBus.md +308 -0
  41. package/src/EventBus.test.ts +295 -0
  42. package/src/EventBus.ts +110 -0
  43. package/src/Feed.md +218 -0
  44. package/src/Feed.test.ts +442 -0
  45. package/src/Feed.ts +101 -0
  46. package/src/Model.md +524 -0
  47. package/src/Model.test.ts +642 -0
  48. package/src/Model.ts +260 -0
  49. package/src/Pagination.md +168 -0
  50. package/src/Pagination.test.ts +244 -0
  51. package/src/Pagination.ts +92 -0
  52. package/src/Pending.md +380 -0
  53. package/src/Pending.test.ts +1719 -0
  54. package/src/Pending.ts +390 -0
  55. package/src/PersistentCollection.md +183 -0
  56. package/src/PersistentCollection.test.ts +649 -0
  57. package/src/PersistentCollection.ts +375 -0
  58. package/src/Resource.ViewModel.test.ts +503 -0
  59. package/src/Resource.md +239 -0
  60. package/src/Resource.test.ts +786 -0
  61. package/src/Resource.ts +231 -0
  62. package/src/Selection.md +155 -0
  63. package/src/Selection.test.ts +326 -0
  64. package/src/Selection.ts +117 -0
  65. package/src/Service.md +440 -0
  66. package/src/Service.test.ts +241 -0
  67. package/src/Service.ts +72 -0
  68. package/src/Sorting.md +170 -0
  69. package/src/Sorting.test.ts +334 -0
  70. package/src/Sorting.ts +135 -0
  71. package/src/Trackable.md +166 -0
  72. package/src/Trackable.test.ts +236 -0
  73. package/src/Trackable.ts +129 -0
  74. package/src/ViewModel.async.test.ts +813 -0
  75. package/src/ViewModel.derived.test.ts +1583 -0
  76. package/src/ViewModel.md +1111 -0
  77. package/src/ViewModel.test.ts +1236 -0
  78. package/src/ViewModel.ts +800 -0
  79. package/src/bindPublicMethods.test.ts +126 -0
  80. package/src/bindPublicMethods.ts +48 -0
  81. package/src/env.d.ts +5 -0
  82. package/src/errors.test.ts +155 -0
  83. package/src/errors.ts +133 -0
  84. package/src/index.ts +49 -0
  85. package/src/produceDraft.md +90 -0
  86. package/src/produceDraft.test.ts +394 -0
  87. package/src/produceDraft.ts +168 -0
  88. package/src/react/components/CardList.md +97 -0
  89. package/src/react/components/CardList.test.tsx +142 -0
  90. package/src/react/components/CardList.tsx +68 -0
  91. package/src/react/components/DataTable.md +179 -0
  92. package/src/react/components/DataTable.test.tsx +599 -0
  93. package/src/react/components/DataTable.tsx +267 -0
  94. package/src/react/components/InfiniteScroll.md +116 -0
  95. package/src/react/components/InfiniteScroll.test.tsx +218 -0
  96. package/src/react/components/InfiniteScroll.tsx +70 -0
  97. package/src/react/components/types.ts +90 -0
  98. package/src/react/derived.test.tsx +261 -0
  99. package/src/react/guards.ts +24 -0
  100. package/src/react/index.ts +40 -0
  101. package/src/react/provider.test.tsx +143 -0
  102. package/src/react/provider.tsx +55 -0
  103. package/src/react/strict-mode.test.tsx +266 -0
  104. package/src/react/types.ts +25 -0
  105. package/src/react/use-event-bus.md +214 -0
  106. package/src/react/use-event-bus.test.tsx +168 -0
  107. package/src/react/use-event-bus.ts +40 -0
  108. package/src/react/use-instance.md +204 -0
  109. package/src/react/use-instance.test.tsx +350 -0
  110. package/src/react/use-instance.ts +60 -0
  111. package/src/react/use-local.md +457 -0
  112. package/src/react/use-local.rapid-remount.test.tsx +503 -0
  113. package/src/react/use-local.test.tsx +692 -0
  114. package/src/react/use-local.ts +165 -0
  115. package/src/react/use-model.md +364 -0
  116. package/src/react/use-model.test.tsx +394 -0
  117. package/src/react/use-model.ts +161 -0
  118. package/src/react/use-singleton.md +415 -0
  119. package/src/react/use-singleton.test.tsx +296 -0
  120. package/src/react/use-singleton.ts +69 -0
  121. package/src/react/use-subscribe-only.ts +39 -0
  122. package/src/react/use-teardown.md +169 -0
  123. package/src/react/use-teardown.test.tsx +86 -0
  124. package/src/react/use-teardown.ts +27 -0
  125. package/src/react-native/NativeCollection.test.ts +250 -0
  126. package/src/react-native/NativeCollection.ts +138 -0
  127. package/src/react-native/index.ts +1 -0
  128. package/src/singleton.md +310 -0
  129. package/src/singleton.test.ts +204 -0
  130. package/src/singleton.ts +70 -0
  131. package/src/types.ts +70 -0
  132. package/src/walkPrototypeChain.ts +22 -0
  133. package/src/web/IndexedDBCollection.test.ts +235 -0
  134. package/src/web/IndexedDBCollection.ts +66 -0
  135. package/src/web/WebStorageCollection.test.ts +214 -0
  136. package/src/web/WebStorageCollection.ts +116 -0
  137. package/src/web/idb.ts +184 -0
  138. package/src/web/index.ts +2 -0
  139. 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
+ });