@milaboratories/uikit 2.5.7 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1218 @@
1
+ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { effectScope, ref } from 'vue';
3
+ import { usePollingQuery } from '../../composition/usePollingQuery';
4
+
5
+ type ControlledCall<Args, Result> = {
6
+ args: Args;
7
+ signal: AbortSignal;
8
+ pause: () => void;
9
+ startTime: number;
10
+ resolve: (value: Result) => void;
11
+ reject: (error?: unknown) => void;
12
+ };
13
+
14
+ function createDeferred<T>() {
15
+ let resolve!: (value: T) => void;
16
+ let reject!: (reason?: unknown) => void;
17
+ const promise = new Promise<T>((res, rej) => {
18
+ resolve = res;
19
+ reject = rej;
20
+ });
21
+ return { promise, resolve, reject };
22
+ }
23
+
24
+ function createControlledQuery<Args, Result>() {
25
+ const calls: ControlledCall<Args, Result>[] = [];
26
+ const fn = vi.fn(
27
+ (callArgs: Args, options: { signal: AbortSignal; pause: () => void }) => {
28
+ const deferred = createDeferred<Result>();
29
+ calls.push({
30
+ args: callArgs,
31
+ signal: options.signal,
32
+ pause: options.pause,
33
+ startTime: Date.now(),
34
+ resolve: deferred.resolve,
35
+ reject: deferred.reject,
36
+ });
37
+ return deferred.promise;
38
+ },
39
+ );
40
+ return { fn, calls };
41
+ }
42
+
43
+ const flushMicrotasks = async () => {
44
+ await Promise.resolve();
45
+ await Promise.resolve();
46
+ };
47
+
48
+ const advanceTime = async (ms: number) => {
49
+ await vi.advanceTimersByTimeAsync(ms);
50
+ await flushMicrotasks();
51
+ };
52
+
53
+ function runInScope<T>(factory: () => T) {
54
+ const scope = effectScope();
55
+ const result = scope.run(factory);
56
+ if (!result) {
57
+ scope.stop();
58
+ throw new Error('Failed to initialise polling scope');
59
+ }
60
+ return { scope, result };
61
+ }
62
+
63
+ describe('usePollingQuery', () => {
64
+ beforeEach(() => {
65
+ vi.useFakeTimers();
66
+ vi.setSystemTime(0);
67
+ });
68
+
69
+ afterEach(() => {
70
+ vi.runAllTimers();
71
+ vi.useRealTimers();
72
+ vi.restoreAllMocks();
73
+ });
74
+
75
+ it('runs immediately when triggerOnResume is true and respects timing constraints', async () => {
76
+ const args = ref({ id: 'alpha' });
77
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
78
+
79
+ const { scope, result } = runInScope(() =>
80
+ usePollingQuery(args, fn, {
81
+ minInterval: 1000,
82
+ minDelay: 200,
83
+ autoStart: true,
84
+ triggerOnResume: true,
85
+ }),
86
+ );
87
+ const { data, lastError, isActive } = result;
88
+
89
+ expect(data.value).toEqual({ status: 'idle' });
90
+ expect(isActive.value).toBe(true);
91
+
92
+ await advanceTime(0);
93
+ expect(fn).toHaveBeenCalledTimes(1);
94
+ expect(calls[0].startTime).toBe(0);
95
+
96
+ await advanceTime(400);
97
+ calls[0].resolve('first');
98
+ await flushMicrotasks();
99
+
100
+ expect(data.value.status).toBe('synced');
101
+ if (data.value.status === 'synced') {
102
+ expect(data.value.value).toBe('first');
103
+ }
104
+ expect(lastError.value).toBeNull();
105
+
106
+ await advanceTime(599);
107
+ expect(fn).toHaveBeenCalledTimes(1);
108
+
109
+ await advanceTime(1);
110
+ expect(fn).toHaveBeenCalledTimes(2);
111
+ expect(calls[1].startTime).toBe(1000);
112
+ expect(isActive.value).toBe(true);
113
+
114
+ scope.stop();
115
+ });
116
+
117
+ it('cycles according to minInterval when callback resolves immediately', async () => {
118
+ const args = ref({ id: 0 });
119
+ const startTimes: number[] = [];
120
+ const fn = vi.fn(async () => {
121
+ startTimes.push(Date.now());
122
+ return 'value';
123
+ });
124
+
125
+ const minInterval = ref(300);
126
+
127
+ const { scope } = runInScope(() =>
128
+ usePollingQuery(args, fn, {
129
+ minInterval,
130
+ autoStart: true,
131
+ triggerOnResume: true,
132
+ }),
133
+ );
134
+
135
+ await advanceTime(0);
136
+ expect(fn).toHaveBeenCalledTimes(1);
137
+ expect(startTimes[0]).toBe(0);
138
+
139
+ await advanceTime(300);
140
+ expect(fn).toHaveBeenCalledTimes(2);
141
+ expect(startTimes[1]).toBe(300);
142
+
143
+ await advanceTime(300);
144
+ expect(fn).toHaveBeenCalledTimes(3);
145
+ expect(startTimes[2]).toBe(600);
146
+
147
+ scope.stop();
148
+ });
149
+
150
+ it('marks status stale on argument change and resyncs after success', async () => {
151
+ const args = ref({ id: 'initial' });
152
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
153
+
154
+ const { scope, result } = runInScope(() =>
155
+ usePollingQuery(args, fn, {
156
+ minInterval: 100,
157
+ triggerOnResume: true,
158
+ autoStart: true,
159
+ }),
160
+ );
161
+ const { data } = result;
162
+
163
+ await advanceTime(0);
164
+ expect(fn).toHaveBeenCalledTimes(1);
165
+
166
+ calls[0].resolve('first');
167
+ await flushMicrotasks();
168
+ expect(data.value).toEqual({ status: 'synced', value: 'first' });
169
+
170
+ args.value = { id: 'next' };
171
+ expect(data.value).toEqual({ status: 'stale', value: 'first' });
172
+
173
+ await advanceTime(100);
174
+ expect(fn).toHaveBeenCalledTimes(2);
175
+ expect(calls[1].args).toEqual({ id: 'next' });
176
+
177
+ calls[1].resolve('second');
178
+ await flushMicrotasks();
179
+ expect(data.value).toEqual({ status: 'synced', value: 'second' });
180
+
181
+ scope.stop();
182
+ });
183
+
184
+ it('remains idle on argument changes before first successful poll', async () => {
185
+ const args = ref({ id: 'initial' });
186
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
187
+
188
+ const { scope, result } = runInScope(() =>
189
+ usePollingQuery(args, fn, {
190
+ minInterval: 100,
191
+ triggerOnResume: true,
192
+ autoStart: true,
193
+ }),
194
+ );
195
+ const { data } = result;
196
+
197
+ await advanceTime(0);
198
+ expect(data.value).toEqual({ status: 'idle' });
199
+
200
+ args.value = { id: 'next' };
201
+ expect(data.value).toEqual({ status: 'idle' });
202
+
203
+ scope.stop();
204
+ expect(calls[0]?.signal.aborted).toBe(true);
205
+ });
206
+
207
+ it('remains idle until resumed when created inactive', async () => {
208
+ const args = ref({ id: 'alpha' });
209
+ const fn = vi.fn(async () => 'first');
210
+
211
+ const { scope, result } = runInScope(() =>
212
+ usePollingQuery(args, fn, {
213
+ minInterval: 250,
214
+ autoStart: false,
215
+ }),
216
+ );
217
+ const { resume, data } = result;
218
+
219
+ expect(fn).not.toHaveBeenCalled();
220
+ expect(data.value.status).toBe('idle');
221
+
222
+ await advanceTime(1000);
223
+ expect(fn).not.toHaveBeenCalled();
224
+
225
+ resume();
226
+ await advanceTime(249);
227
+ expect(fn).not.toHaveBeenCalled();
228
+
229
+ await advanceTime(1);
230
+ expect(fn).toHaveBeenCalledTimes(1);
231
+
232
+ scope.stop();
233
+ });
234
+
235
+ it('debounces argument changes and aborts in-flight requests', async () => {
236
+ const args = ref({ q: 'initial' });
237
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
238
+
239
+ const { scope } = runInScope(() =>
240
+ usePollingQuery(args, fn, {
241
+ minInterval: 100,
242
+ autoStart: true,
243
+ triggerOnResume: true,
244
+ debounce: 200,
245
+ }),
246
+ );
247
+
248
+ await advanceTime(0);
249
+ expect(fn).toHaveBeenCalledTimes(1);
250
+
251
+ args.value = { q: 'first' };
252
+ expect(calls[0].signal.aborted).toBe(true);
253
+ args.value = { q: 'second' };
254
+ expect(calls[0].signal.aborted).toBe(true);
255
+
256
+ calls[0].reject(new Error('aborted'));
257
+ await flushMicrotasks();
258
+ await advanceTime(0);
259
+
260
+ await advanceTime(199);
261
+ expect(fn).toHaveBeenCalledTimes(1);
262
+
263
+ await advanceTime(201);
264
+ await advanceTime(0);
265
+ expect(fn).toHaveBeenCalledTimes(2);
266
+ expect(calls[1].args).toEqual({ q: 'second' });
267
+
268
+ scope.stop();
269
+ });
270
+
271
+ it('restarts debounce window on rapid argument updates', async () => {
272
+ const args = ref({ value: 1 });
273
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
274
+
275
+ const { scope } = runInScope(() =>
276
+ usePollingQuery(args, fn, {
277
+ minInterval: 100,
278
+ autoStart: true,
279
+ triggerOnResume: true,
280
+ debounce: 150,
281
+ }),
282
+ );
283
+
284
+ await advanceTime(0);
285
+ expect(calls).toHaveLength(1);
286
+
287
+ args.value = { value: 2 };
288
+ expect(calls[0].signal.aborted).toBe(true);
289
+ calls[0].reject(new Error('aborted'));
290
+ await flushMicrotasks();
291
+ await advanceTime(0);
292
+
293
+ await advanceTime(100);
294
+ args.value = { value: 3 };
295
+
296
+ await advanceTime(100);
297
+ args.value = { value: 4 };
298
+
299
+ await advanceTime(149);
300
+ expect(calls).toHaveLength(1);
301
+
302
+ await advanceTime(1);
303
+ await advanceTime(200);
304
+ await advanceTime(0);
305
+ expect(calls).toHaveLength(2);
306
+ expect(calls[1].args).toEqual({ value: 4 });
307
+ expect(calls[1].startTime).toBeGreaterThanOrEqual(350);
308
+
309
+ scope.stop();
310
+ });
311
+
312
+ it('pauses polling and aborts controllers', async () => {
313
+ const args = ref({ id: 1 });
314
+ const { fn, calls } = createControlledQuery<typeof args.value, number>();
315
+
316
+ const { scope, result } = runInScope(() =>
317
+ usePollingQuery(args, fn, {
318
+ minInterval: 100,
319
+ autoStart: true,
320
+ triggerOnResume: true,
321
+ }),
322
+ );
323
+ const { pause, resume, isActive } = result;
324
+
325
+ await advanceTime(0);
326
+ expect(fn).toHaveBeenCalledTimes(1);
327
+ expect(isActive.value).toBe(true);
328
+
329
+ pause();
330
+ expect(isActive.value).toBe(false);
331
+ expect(calls[0].signal.aborted).toBe(true);
332
+
333
+ calls[0].reject(new Error('aborted'));
334
+ await flushMicrotasks();
335
+
336
+ await advanceTime(500);
337
+ expect(fn).toHaveBeenCalledTimes(1);
338
+
339
+ resume();
340
+ await advanceTime(0);
341
+ expect(isActive.value).toBe(true);
342
+ expect(fn).toHaveBeenCalledTimes(2);
343
+
344
+ scope.stop();
345
+ });
346
+
347
+ it('ignores argument changes while paused until resumed', async () => {
348
+ const args = ref({ id: 1 });
349
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
350
+
351
+ const { scope, result } = runInScope(() =>
352
+ usePollingQuery(args, fn, {
353
+ minInterval: 100,
354
+ autoStart: true,
355
+ triggerOnResume: true,
356
+ }),
357
+ );
358
+ const { pause, resume } = result;
359
+
360
+ await advanceTime(0);
361
+ expect(fn).toHaveBeenCalledTimes(1);
362
+
363
+ pause();
364
+ args.value = { id: 2 };
365
+ await advanceTime(500);
366
+ expect(fn).toHaveBeenCalledTimes(1);
367
+
368
+ calls[0].reject(new Error('aborted'));
369
+ await flushMicrotasks();
370
+
371
+ resume();
372
+ await advanceTime(0);
373
+ expect(fn).toHaveBeenCalledTimes(2);
374
+ expect(calls[1].args).toEqual({ id: 2 });
375
+
376
+ scope.stop();
377
+ });
378
+
379
+ it('auto pauses on error when pauseOnError is enabled', async () => {
380
+ const args = ref('value');
381
+ const { fn, calls } = createControlledQuery<string, string>();
382
+
383
+ const { scope, result } = runInScope(() =>
384
+ usePollingQuery(args, fn, {
385
+ minInterval: 100,
386
+ autoStart: true,
387
+ triggerOnResume: true,
388
+ pauseOnError: true,
389
+ }),
390
+ );
391
+ const { lastError, isActive } = result;
392
+
393
+ await advanceTime(0);
394
+ expect(fn).toHaveBeenCalledTimes(1);
395
+
396
+ calls[0].reject(new Error('failure'));
397
+ await flushMicrotasks();
398
+
399
+ expect(lastError.value?.message).toBe('failure');
400
+ expect(isActive.value).toBe(false);
401
+
402
+ await advanceTime(500);
403
+ expect(fn).toHaveBeenCalledTimes(1);
404
+
405
+ scope.stop();
406
+ });
407
+
408
+ it('continues polling after errors when pauseOnError is disabled', async () => {
409
+ const args = ref('value');
410
+ let attempt = 0;
411
+ const fn = vi.fn(async () => {
412
+ attempt += 1;
413
+ if (attempt === 1) throw new Error('boom');
414
+ return 'ok';
415
+ });
416
+
417
+ const { scope, result } = runInScope(() =>
418
+ usePollingQuery(args, fn, {
419
+ minInterval: 100,
420
+ autoStart: true,
421
+ triggerOnResume: true,
422
+ }),
423
+ );
424
+ const { lastError, data } = result;
425
+
426
+ await advanceTime(0);
427
+ expect(lastError.value).toBeInstanceOf(Error);
428
+ expect(data.value.status).toBe('idle');
429
+
430
+ await advanceTime(100);
431
+ expect(fn).toHaveBeenCalledTimes(2);
432
+ expect(lastError.value).toBeNull();
433
+ if (data.value.status === 'synced') {
434
+ expect(data.value.value).toBe('ok');
435
+ } else {
436
+ throw new Error('Expected polling data to be synced after successful retry');
437
+ }
438
+
439
+ scope.stop();
440
+ });
441
+
442
+ it('clears lastError on resume', async () => {
443
+ const args = ref('value');
444
+ const { fn, calls } = createControlledQuery<string, string>();
445
+
446
+ const { scope, result } = runInScope(() =>
447
+ usePollingQuery(args, fn, {
448
+ minInterval: 100,
449
+ autoStart: true,
450
+ triggerOnResume: true,
451
+ pauseOnError: true,
452
+ }),
453
+ );
454
+ const { lastError, resume } = result;
455
+
456
+ await advanceTime(0);
457
+ calls[0].reject(new Error('failure'));
458
+ await flushMicrotasks();
459
+ expect(lastError.value).toBeInstanceOf(Error);
460
+
461
+ resume();
462
+ expect(lastError.value).toBeNull();
463
+
464
+ scope.stop();
465
+ });
466
+
467
+ it('converts thrown primitives into Error instances', async () => {
468
+ const args = ref('value');
469
+ const fn = vi.fn(async () => {
470
+ throw 'failure';
471
+ });
472
+
473
+ const { scope, result } = runInScope(() =>
474
+ usePollingQuery(args, fn, {
475
+ minInterval: 100,
476
+ autoStart: true,
477
+ triggerOnResume: true,
478
+ }),
479
+ );
480
+ const { lastError } = result;
481
+
482
+ await advanceTime(0);
483
+ expect(lastError.value).toBeInstanceOf(Error);
484
+ expect(lastError.value?.message).toBe('failure');
485
+
486
+ scope.stop();
487
+ });
488
+
489
+ it('ignores outdated results thanks to version tracking', async () => {
490
+ const args = ref({ id: 'a' });
491
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
492
+
493
+ const { scope, result } = runInScope(() =>
494
+ usePollingQuery(args, fn, {
495
+ minInterval: 10,
496
+ autoStart: true,
497
+ triggerOnResume: true,
498
+ maxInFlightRequests: 2,
499
+ }),
500
+ );
501
+ const { data } = result;
502
+
503
+ await advanceTime(0);
504
+ args.value = { id: 'b' };
505
+ await advanceTime(10);
506
+
507
+ expect(fn).toHaveBeenCalledTimes(2);
508
+
509
+ calls[1].resolve('latest');
510
+ await flushMicrotasks();
511
+ expect(data.value).toEqual({ status: 'synced', value: 'latest' });
512
+
513
+ calls[0].resolve('stale');
514
+ await flushMicrotasks();
515
+ expect(data.value).toEqual({ status: 'synced', value: 'latest' });
516
+
517
+ scope.stop();
518
+ });
519
+
520
+ it('allows overlapping requests up to the configured limit', async () => {
521
+ const args = ref({ id: 'a' });
522
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
523
+
524
+ const { scope } = runInScope(() =>
525
+ usePollingQuery(args, fn, {
526
+ minInterval: 20,
527
+ autoStart: true,
528
+ triggerOnResume: true,
529
+ maxInFlightRequests: 2,
530
+ }),
531
+ );
532
+
533
+ await advanceTime(0);
534
+ expect(fn).toHaveBeenCalledTimes(1);
535
+
536
+ args.value = { id: 'b' };
537
+ await advanceTime(20);
538
+ expect(fn).toHaveBeenCalledTimes(2);
539
+
540
+ args.value = { id: 'c' };
541
+ await advanceTime(20);
542
+ expect(fn).toHaveBeenCalledTimes(2);
543
+
544
+ calls[0].resolve('first');
545
+ await flushMicrotasks();
546
+ await advanceTime(0);
547
+ expect(fn).toHaveBeenCalledTimes(3);
548
+ expect(calls[2].args).toEqual({ id: 'c' });
549
+
550
+ scope.stop();
551
+ });
552
+
553
+ it('exposes reactive inFlightCount', async () => {
554
+ const args = ref({ id: 'seed' });
555
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
556
+
557
+ const { scope, result } = runInScope(() =>
558
+ usePollingQuery(args, fn, {
559
+ minInterval: 20,
560
+ autoStart: true,
561
+ triggerOnResume: true,
562
+ maxInFlightRequests: 2,
563
+ }),
564
+ );
565
+ const { inFlightCount } = result;
566
+
567
+ expect(inFlightCount.value).toBe(0);
568
+
569
+ await advanceTime(0);
570
+ expect(inFlightCount.value).toBe(1);
571
+
572
+ args.value = { id: 'next' };
573
+ await advanceTime(20);
574
+ expect(inFlightCount.value).toBe(2);
575
+
576
+ calls[0].resolve('first');
577
+ await flushMicrotasks();
578
+ expect(inFlightCount.value).toBe(1);
579
+
580
+ calls[1].resolve('second');
581
+ await flushMicrotasks();
582
+ expect(inFlightCount.value).toBe(0);
583
+
584
+ scope.stop();
585
+ });
586
+
587
+ it('keeps inFlightCount > 0 for aborted but unresolved calls', async () => {
588
+ const args = ref({ id: 1 });
589
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
590
+
591
+ const { scope, result } = runInScope(() =>
592
+ usePollingQuery(args, fn, {
593
+ minInterval: 100,
594
+ autoStart: true,
595
+ triggerOnResume: true,
596
+ }),
597
+ );
598
+ const { pause, inFlightCount } = result;
599
+
600
+ await advanceTime(0);
601
+ expect(inFlightCount.value).toBe(1);
602
+
603
+ pause();
604
+ expect(calls[0].signal.aborted).toBe(true);
605
+ expect(inFlightCount.value).toBe(1);
606
+
607
+ calls[0].reject(new Error('aborted'));
608
+ await flushMicrotasks();
609
+ expect(inFlightCount.value).toBe(0);
610
+
611
+ scope.stop();
612
+ });
613
+
614
+ it('respects maxInFlightRequests when limit reached', async () => {
615
+ const args = ref({ id: 'a' });
616
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
617
+
618
+ const { scope } = runInScope(() =>
619
+ usePollingQuery(args, fn, {
620
+ minInterval: 50,
621
+ autoStart: true,
622
+ triggerOnResume: true,
623
+ maxInFlightRequests: 1,
624
+ }),
625
+ );
626
+
627
+ await advanceTime(0);
628
+ await advanceTime(0);
629
+ expect(fn).toHaveBeenCalledTimes(1);
630
+
631
+ args.value = { id: 'b' };
632
+ await advanceTime(50);
633
+ expect(fn).toHaveBeenCalledTimes(1);
634
+
635
+ calls[0].resolve('ignored');
636
+ await flushMicrotasks();
637
+
638
+ await advanceTime(0);
639
+ expect(fn).toHaveBeenCalledTimes(2);
640
+ expect(calls[1].args).toEqual({ id: 'b' });
641
+
642
+ scope.stop();
643
+ });
644
+
645
+ it('allows callback to pause polling via provided helper', async () => {
646
+ const args = ref(1);
647
+ const fn = vi.fn(async (_: number, { pause }: { pause: () => void }) => {
648
+ pause();
649
+ return 42;
650
+ });
651
+
652
+ const { scope, result } = runInScope(() =>
653
+ usePollingQuery(args, fn, {
654
+ minInterval: 100,
655
+ autoStart: true,
656
+ triggerOnResume: true,
657
+ }),
658
+ );
659
+ const { isActive } = result;
660
+
661
+ await advanceTime(0);
662
+
663
+ expect(fn).toHaveBeenCalledTimes(1);
664
+ expect(isActive.value).toBe(false);
665
+
666
+ await advanceTime(500);
667
+ expect(fn).toHaveBeenCalledTimes(1);
668
+
669
+ scope.stop();
670
+ });
671
+
672
+ it('resumes after pause when triggerOnResume is disabled', async () => {
673
+ const args = ref(1);
674
+ const fn = vi.fn(async () => 'value');
675
+
676
+ const { scope, result } = runInScope(() =>
677
+ usePollingQuery(args, fn, {
678
+ minInterval: 200,
679
+ autoStart: false,
680
+ triggerOnResume: false,
681
+ }),
682
+ );
683
+ const { resume } = result;
684
+
685
+ resume();
686
+ await advanceTime(199);
687
+ expect(fn).not.toHaveBeenCalled();
688
+
689
+ await advanceTime(1);
690
+ expect(fn).toHaveBeenCalledTimes(1);
691
+
692
+ scope.stop();
693
+ });
694
+
695
+ it('does not start when minInterval is non-positive', async () => {
696
+ const args = ref({ id: 'nope' });
697
+ const fn = vi.fn(async () => 'value');
698
+
699
+ const minInterval = ref(0);
700
+
701
+ const { scope, result } = runInScope(() =>
702
+ usePollingQuery(args, fn, {
703
+ minInterval,
704
+ autoStart: true,
705
+ triggerOnResume: true,
706
+ }),
707
+ );
708
+ const { isActive, resume } = result;
709
+
710
+ expect(isActive.value).toBe(false);
711
+ await advanceTime(0);
712
+ expect(fn).not.toHaveBeenCalled();
713
+
714
+ resume();
715
+ await advanceTime(0);
716
+ expect(fn).not.toHaveBeenCalled();
717
+
718
+ scope.stop();
719
+ });
720
+
721
+ it('enforces minDelay when callback duration exceeds minInterval', async () => {
722
+ const args = ref({ id: 'delayed' });
723
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
724
+
725
+ const minDelay = ref(150);
726
+
727
+ const { scope } = runInScope(() =>
728
+ usePollingQuery(args, fn, {
729
+ minInterval: 200,
730
+ minDelay,
731
+ autoStart: true,
732
+ triggerOnResume: true,
733
+ }),
734
+ );
735
+
736
+ await advanceTime(0);
737
+ const firstStart = calls[0].startTime;
738
+ await advanceTime(350);
739
+ calls[0].resolve('done');
740
+ await flushMicrotasks();
741
+
742
+ await advanceTime(149);
743
+ expect(fn).toHaveBeenCalledTimes(1);
744
+
745
+ await advanceTime(1);
746
+ expect(fn).toHaveBeenCalledTimes(2);
747
+ expect(calls[1].startTime - firstStart).toBeGreaterThanOrEqual(500);
748
+
749
+ scope.stop();
750
+ });
751
+
752
+ it('aborts active requests when scope is disposed', async () => {
753
+ const args = ref({ id: 'cleanup' });
754
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
755
+
756
+ const { scope } = runInScope(() =>
757
+ usePollingQuery(args, fn, {
758
+ minInterval: 100,
759
+ autoStart: true,
760
+ triggerOnResume: true,
761
+ }),
762
+ );
763
+
764
+ await advanceTime(0);
765
+ expect(fn).toHaveBeenCalledTimes(1);
766
+ const controller = calls[0].signal;
767
+ expect(controller.aborted).toBe(false);
768
+
769
+ scope.stop();
770
+ expect(controller.aborted).toBe(true);
771
+ });
772
+
773
+ // EDGE CASE TESTS - These should fail with current implementation
774
+
775
+ it('handles concurrent pause() calls from within callback', async () => {
776
+ const args = ref({ id: 'concurrent' });
777
+ let pauseFn: (() => void) | undefined;
778
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
779
+ const fn = vi.fn(async (_: any, { pause }: { pause: () => void }) => {
780
+ pauseFn = pause;
781
+ // Simulate async operation where pause might be called multiple times
782
+ await new Promise((resolve) => setTimeout(resolve, 50));
783
+ pause(); // First pause
784
+ pause(); // Second pause - should be idempotent
785
+ return 'result';
786
+ });
787
+
788
+ const { scope, result } = runInScope(() =>
789
+ usePollingQuery(args, fn, {
790
+ minInterval: 100,
791
+ autoStart: true,
792
+ triggerOnResume: true,
793
+ }),
794
+ );
795
+ const { isActive, pause: externalPause } = result;
796
+
797
+ await advanceTime(0);
798
+ expect(fn).toHaveBeenCalledTimes(1);
799
+ expect(isActive.value).toBe(true);
800
+
801
+ // Call pause from outside while callback is executing
802
+ externalPause();
803
+ expect(isActive.value).toBe(false);
804
+
805
+ // The callback's pause should not reactivate or cause issues
806
+ pauseFn?.();
807
+
808
+ await advanceTime(100);
809
+ expect(isActive.value).toBe(false);
810
+ expect(fn).toHaveBeenCalledTimes(1);
811
+
812
+ scope.stop();
813
+ });
814
+
815
+ it('eventually processes latest args after debounce pause/resume', async () => {
816
+ const args = ref({ value: 1 });
817
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
818
+
819
+ const { scope, result } = runInScope(() =>
820
+ usePollingQuery(args, fn, {
821
+ minInterval: 100,
822
+ autoStart: true,
823
+ triggerOnResume: true,
824
+ debounce: 200,
825
+ }),
826
+ );
827
+ const { pause, resume, data } = result;
828
+
829
+ await advanceTime(0);
830
+ expect(fn).toHaveBeenCalledTimes(1);
831
+ calls[0].resolve('initial');
832
+ await flushMicrotasks();
833
+ expect(data.value).toEqual({ status: 'synced', value: 'initial' });
834
+
835
+ // Change args to start debounce
836
+ args.value = { value: 2 };
837
+ expect(data.value).toEqual({ status: 'stale', value: 'initial' });
838
+
839
+ // Pause during debounce
840
+ await advanceTime(100);
841
+ pause();
842
+
843
+ // Change args while paused
844
+ args.value = { value: 3 };
845
+
846
+ // Resume - first poll may use the pre-pause snapshot, but subsequent polls must converge
847
+ resume();
848
+ await advanceTime(0);
849
+ expect(fn).toHaveBeenCalledTimes(2);
850
+ expect(calls[1].args).toEqual({ value: 2 });
851
+
852
+ calls[1].resolve('second');
853
+ await flushMicrotasks();
854
+
855
+ await advanceTime(100);
856
+ expect(fn).toHaveBeenCalledTimes(3);
857
+ expect(calls[2].args).toEqual({ value: 3 });
858
+
859
+ calls[2].resolve('latest');
860
+ await flushMicrotasks();
861
+ expect(data.value).toEqual({ status: 'synced', value: 'latest' });
862
+
863
+ scope.stop();
864
+ });
865
+
866
+ it('correctly tracks versions when error occurs during argument change', async () => {
867
+ const args = ref({ id: 'a' });
868
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
869
+
870
+ const { scope, result } = runInScope(() =>
871
+ usePollingQuery(args, fn, {
872
+ minInterval: 50,
873
+ autoStart: true,
874
+ triggerOnResume: true,
875
+ maxInFlightRequests: 2,
876
+ }),
877
+ );
878
+ const { data, lastError } = result;
879
+
880
+ await advanceTime(0);
881
+ expect(fn).toHaveBeenCalledTimes(1);
882
+
883
+ // Change args while first request is in flight
884
+ args.value = { id: 'b' };
885
+ await advanceTime(50);
886
+ expect(fn).toHaveBeenCalledTimes(2);
887
+
888
+ // First request fails after args changed
889
+ calls[0].reject(new Error('error-a'));
890
+ await flushMicrotasks();
891
+
892
+ // Error from old version should not set lastError
893
+ expect(lastError.value).toBeNull();
894
+ expect(data.value).toEqual({ status: 'idle' });
895
+
896
+ // Second request succeeds
897
+ calls[1].resolve('result-b');
898
+ await flushMicrotasks();
899
+ expect(data.value).toEqual({ status: 'synced', value: 'result-b' });
900
+ expect(lastError.value).toBeNull();
901
+
902
+ scope.stop();
903
+ });
904
+
905
+ it('handles rapid minDelay changes during execution', async () => {
906
+ const args = ref({ id: 'test' });
907
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
908
+ const minDelay = ref<number | undefined>(100);
909
+
910
+ const { scope } = runInScope(() =>
911
+ usePollingQuery(args, fn, {
912
+ minInterval: 50,
913
+ minDelay,
914
+ autoStart: true,
915
+ triggerOnResume: true,
916
+ }),
917
+ );
918
+
919
+ await advanceTime(0);
920
+ expect(fn).toHaveBeenCalledTimes(1);
921
+
922
+ // Start time of first request
923
+ const firstStart = calls[0].startTime;
924
+
925
+ // Change minDelay to undefined while request is in flight
926
+ minDelay.value = undefined;
927
+
928
+ // Complete first request after 30ms
929
+ await advanceTime(30);
930
+ calls[0].resolve('first');
931
+ await flushMicrotasks();
932
+
933
+ // With minDelay now undefined, next poll should start at minInterval (50ms from start)
934
+ await advanceTime(19);
935
+ expect(fn).toHaveBeenCalledTimes(1);
936
+
937
+ await advanceTime(1);
938
+ expect(fn).toHaveBeenCalledTimes(2);
939
+ expect(calls[1].startTime).toBe(firstStart + 50);
940
+
941
+ scope.stop();
942
+ });
943
+
944
+ it('prevents duplicate scheduling when args change completes a request', async () => {
945
+ const args = ref({ id: 'a' });
946
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
947
+
948
+ const { scope } = runInScope(() =>
949
+ usePollingQuery(args, fn, {
950
+ minInterval: 100,
951
+ autoStart: true,
952
+ triggerOnResume: true,
953
+ }),
954
+ );
955
+
956
+ await advanceTime(0);
957
+ expect(fn).toHaveBeenCalledTimes(1);
958
+
959
+ // Change args while request is in flight
960
+ args.value = { id: 'b' };
961
+
962
+ // This aborts the first request and schedules a new one
963
+ // But when the first request completes (even if aborted), it should not schedule again
964
+
965
+ calls[0].reject(new Error('aborted'));
966
+ await flushMicrotasks();
967
+
968
+ // Should schedule only once for the new args
969
+ await advanceTime(100);
970
+ expect(fn).toHaveBeenCalledTimes(2);
971
+ expect(calls[1].args).toEqual({ id: 'b' });
972
+
973
+ // Complete second request and wait for next poll
974
+ calls[1].resolve('result-b');
975
+ await flushMicrotasks();
976
+
977
+ await advanceTime(100);
978
+ // Should have exactly one more call, not duplicated
979
+ expect(fn).toHaveBeenCalledTimes(3);
980
+ expect(calls[2].args).toEqual({ id: 'b' });
981
+
982
+ scope.stop();
983
+ });
984
+
985
+ it('cleans up waiters array on dispose to prevent memory leak', async () => {
986
+ const args = ref({ id: 'test' });
987
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
988
+
989
+ const { scope, result } = runInScope(() =>
990
+ usePollingQuery(args, fn, {
991
+ minInterval: 50,
992
+ autoStart: true,
993
+ triggerOnResume: true,
994
+ maxInFlightRequests: 1,
995
+ }),
996
+ );
997
+ const { inFlightCount } = result;
998
+
999
+ // Start first request
1000
+ await advanceTime(0);
1001
+ expect(fn).toHaveBeenCalledTimes(1);
1002
+ expect(inFlightCount.value).toBe(1);
1003
+
1004
+ // Try to change args multiple times while first request is in flight
1005
+ // This should queue up waiters
1006
+ args.value = { id: 'b' };
1007
+ await advanceTime(50);
1008
+ args.value = { id: 'c' };
1009
+ await advanceTime(50);
1010
+ args.value = { id: 'd' };
1011
+
1012
+ // Dispose scope while waiters are queued
1013
+ scope.stop();
1014
+
1015
+ // Complete the in-flight request after dispose
1016
+ calls[0].resolve('result');
1017
+ await flushMicrotasks();
1018
+
1019
+ // Should not throw or cause issues
1020
+ expect(inFlightCount.value).toBe(0);
1021
+ });
1022
+
1023
+ it('handles argument changes after pause but before resumeimmediately after arguments provided', async () => {
1024
+ const args = ref({ value: 1 });
1025
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
1026
+
1027
+ const { scope, result } = runInScope(() =>
1028
+ usePollingQuery(args, fn, {
1029
+ minInterval: 100,
1030
+ autoStart: false,
1031
+ triggerOnResume: false,
1032
+ }),
1033
+ );
1034
+ const { resume, data, isActive } = result;
1035
+
1036
+ // Provide args but don't resume yet
1037
+ expect(data.value).toEqual({ status: 'idle' });
1038
+ expect(isActive.value).toBe(false);
1039
+
1040
+ // Change args before first resume
1041
+ args.value = { value: 2 };
1042
+ args.value = { value: 3 };
1043
+
1044
+ // Now resume - should use latest args
1045
+ resume();
1046
+ expect(isActive.value).toBe(true);
1047
+
1048
+ await advanceTime(100);
1049
+ expect(fn).toHaveBeenCalledTimes(1);
1050
+ expect(calls[0].args).toEqual({ value: 3 });
1051
+
1052
+ scope.stop();
1053
+ });
1054
+
1055
+ it('correctly handles error from outdated version when maxInFlightRequests > 1', async () => {
1056
+ const args = ref({ id: 'a' });
1057
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
1058
+
1059
+ const { scope, result } = runInScope(() =>
1060
+ usePollingQuery(args, fn, {
1061
+ minInterval: 50,
1062
+ autoStart: true,
1063
+ triggerOnResume: true,
1064
+ maxInFlightRequests: 3,
1065
+ }),
1066
+ );
1067
+ const { data: _data, lastError } = result;
1068
+
1069
+ await advanceTime(0);
1070
+ expect(fn).toHaveBeenCalledTimes(1);
1071
+
1072
+ // Change args - this aborts the in-flight request and schedules a new poll
1073
+ args.value = { id: 'b' };
1074
+ await advanceTime(50);
1075
+ expect(fn).toHaveBeenCalledTimes(2);
1076
+
1077
+ // Change args again to ensure multiple versions overlap
1078
+ args.value = { id: 'c' };
1079
+ await advanceTime(50);
1080
+ expect(fn).toHaveBeenCalledTimes(3);
1081
+
1082
+ // First request (outdated) fails
1083
+ calls[0].reject(new Error('error-old-1'));
1084
+ await flushMicrotasks();
1085
+
1086
+ // Should not set lastError from old version
1087
+ expect(lastError.value).toBeNull();
1088
+
1089
+ // Second request (also outdated) fails
1090
+ calls[1].reject(new Error('error-old-2'));
1091
+ await flushMicrotasks();
1092
+
1093
+ // Still should not set lastError
1094
+ expect(lastError.value).toBeNull();
1095
+
1096
+ // Current request fails
1097
+ calls[2].reject(new Error('error-current'));
1098
+ await flushMicrotasks();
1099
+
1100
+ // Now lastError should be set
1101
+ expect(lastError.value?.message).toBe('error-current');
1102
+
1103
+ scope.stop();
1104
+ });
1105
+
1106
+ it('respects minInterval when triggerOnResume is false and resuming after pause', async () => {
1107
+ const args = ref({ id: 'test' });
1108
+ const { fn, calls } = createControlledQuery<typeof args.value, string>();
1109
+
1110
+ const { scope, result } = runInScope(() =>
1111
+ usePollingQuery(args, fn, {
1112
+ minInterval: 300,
1113
+ autoStart: true,
1114
+ triggerOnResume: false, // Important: false here
1115
+ }),
1116
+ );
1117
+ const { pause, resume } = result;
1118
+
1119
+ // Start with autoStart, should wait for minInterval
1120
+ await advanceTime(299);
1121
+ expect(fn).not.toHaveBeenCalled();
1122
+ await advanceTime(1);
1123
+ expect(fn).toHaveBeenCalledTimes(1);
1124
+
1125
+ // Pause immediately
1126
+ pause();
1127
+ calls[0].reject(new Error('aborted'));
1128
+ await flushMicrotasks();
1129
+
1130
+ // Resume - should wait for minInterval again, not trigger immediately
1131
+ resume();
1132
+ await advanceTime(299);
1133
+ expect(fn).toHaveBeenCalledTimes(1); // Still just 1 call
1134
+
1135
+ await advanceTime(1);
1136
+ expect(fn).toHaveBeenCalledTimes(2); // Now 2 calls
1137
+
1138
+ scope.stop();
1139
+ });
1140
+
1141
+ it('handles simultaneous pause from callback and external pause', async () => {
1142
+ const args = ref({ id: 'test' });
1143
+ let callbackPause: (() => void) | undefined;
1144
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1145
+ const fn = vi.fn(async (_: any, { pause }: { pause: () => void }) => {
1146
+ callbackPause = pause;
1147
+ // Wait for external code to run
1148
+ await new Promise((resolve) => setTimeout(resolve, 10));
1149
+ return 'result';
1150
+ });
1151
+
1152
+ const { scope, result } = runInScope(() =>
1153
+ usePollingQuery(args, fn, {
1154
+ minInterval: 100,
1155
+ autoStart: true,
1156
+ triggerOnResume: true,
1157
+ }),
1158
+ );
1159
+ const { pause: externalPause, resume, isActive } = result;
1160
+
1161
+ await advanceTime(0);
1162
+ expect(fn).toHaveBeenCalledTimes(1);
1163
+ expect(isActive.value).toBe(true);
1164
+
1165
+ // Both pause at nearly the same time
1166
+ await advanceTime(5);
1167
+ callbackPause?.(); // Pause from callback
1168
+ externalPause(); // Pause from external
1169
+
1170
+ expect(isActive.value).toBe(false);
1171
+
1172
+ await advanceTime(100);
1173
+ expect(fn).toHaveBeenCalledTimes(1); // No additional calls
1174
+
1175
+ // Resume should work normally
1176
+ resume();
1177
+ await advanceTime(0);
1178
+ expect(isActive.value).toBe(true);
1179
+ expect(fn).toHaveBeenCalledTimes(2);
1180
+
1181
+ scope.stop();
1182
+ });
1183
+
1184
+ it('handles reactive minInterval becoming positive after being zero', async () => {
1185
+ const args = ref({ id: 'test' });
1186
+ const fn = vi.fn(async () => 'result');
1187
+ const minInterval = ref(0);
1188
+
1189
+ const { scope, result } = runInScope(() =>
1190
+ usePollingQuery(args, fn, {
1191
+ minInterval,
1192
+ autoStart: true,
1193
+ triggerOnResume: true,
1194
+ }),
1195
+ );
1196
+ const { isActive, resume } = result;
1197
+
1198
+ // Should not start because minInterval is 0
1199
+ expect(isActive.value).toBe(false);
1200
+ await advanceTime(100);
1201
+ expect(fn).not.toHaveBeenCalled();
1202
+
1203
+ // Change minInterval to positive value
1204
+ minInterval.value = 100;
1205
+
1206
+ // Resume should now work
1207
+ resume();
1208
+ expect(isActive.value).toBe(true);
1209
+ await advanceTime(0);
1210
+ expect(fn).toHaveBeenCalledTimes(1);
1211
+
1212
+ // Should continue polling
1213
+ await advanceTime(100);
1214
+ expect(fn).toHaveBeenCalledTimes(2);
1215
+
1216
+ scope.stop();
1217
+ });
1218
+ });