@milaboratories/uikit 2.5.6 → 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,415 @@
1
+ import type { MaybeRef, WatchSource } from 'vue';
2
+ import { onScopeDispose, readonly, ref, shallowRef, toValue, watch } from 'vue';
3
+
4
+ type AbortReason = 'args' | 'pause' | 'dispose';
5
+
6
+ type PollingData<Result> =
7
+ | { status: 'idle' }
8
+ | { status: 'synced'; value: Result }
9
+ | { status: 'stale'; value: Result };
10
+
11
+ interface InternalOptions {
12
+ minInterval: MaybeRef<number>;
13
+ minDelay: MaybeRef<number | undefined>;
14
+ autoStart: boolean;
15
+ triggerOnResume: boolean;
16
+ pauseOnError: boolean;
17
+ maxInFlightRequests: number;
18
+ debounce: number;
19
+ }
20
+
21
+ interface Waiter {
22
+ resolve: () => void;
23
+ }
24
+
25
+ const enum ScheduleSource {
26
+ Normal = 'normal',
27
+ External = 'external',
28
+ }
29
+
30
+ function toError(error: unknown): Error {
31
+ if (error instanceof Error) return error;
32
+ return new Error(typeof error === 'string' ? error : JSON.stringify(error));
33
+ }
34
+
35
+ /**
36
+ * Repeatedly executes an asynchronous query while tracking arguments, state transitions,
37
+ * and result freshness.
38
+ *
39
+ * @remarks
40
+ *
41
+ * ### Typical usage
42
+ *
43
+ * ```ts
44
+ * const args = ref({ id: 'item-1' });
45
+ * const { data, pause, resume, lastError } = usePollingQuery(args, fetchItem, {
46
+ * minInterval: 5_000,
47
+ * minDelay: 250,
48
+ * });
49
+ * ```
50
+ *
51
+ * The composable polls `fetchItem` while `resume()`d. Whenever the `args` ref changes the current
52
+ * request is aborted, the status becomes `'stale'`, and a new poll is scheduled after the optional
53
+ * debounce period and the configured timing constraints. Results from older requests are ignored
54
+ * through version tracking, ensuring consumers only observe the freshest payload.
55
+ *
56
+ * ### Timing behaviour
57
+ *
58
+ * - `minInterval` defines the minimum duration between the start times of consecutive polls.
59
+ * - `minDelay` (optional) enforces a minimum wait time between a poll finishing and the next poll starting.
60
+ * - After each poll completes, the next poll is scheduled `max(minInterval - elapsed, minDelay)` ms later.
61
+ * - When arguments change, the next poll still respects both constraints while also honouring the debounce.
62
+ *
63
+ * ### Abort handling
64
+ *
65
+ * Each poll receives a dedicated `AbortSignal`. The signal is aborted when pausing, disposing
66
+ * the scope, or when the arguments ref changes. Queries should surface aborts by listening to
67
+ * the signal. Aborted requests may settle later; outdated results are discarded via version checks.
68
+ *
69
+ * ### Pause, resume, and callback control
70
+ *
71
+ * - `pause()` stops future polls, clears pending timeouts, and aborts in-flight requests.
72
+ * - `resume()` is idempotent; it reactivates polling only when currently inactive.
73
+ * - The callback receives a bound `pause()` helper for conditional pausing.
74
+ *
75
+ * ### Error handling
76
+ *
77
+ * Errors bubble into `lastError`; they reset on the next successful poll or when `resume()`
78
+ * transitions from inactive to active. With `pauseOnError: true` the composable pauses automatically.
79
+ *
80
+ * ### Argument tracking
81
+ *
82
+ * - Initial state is `{ status: 'idle' }`.
83
+ * - Argument changes mark the status `'stale'` when a prior result exists; otherwise it stays `'idle'`.
84
+ * - A successful poll for the latest arguments marks the status `'synced'` and updates `value`.
85
+ *
86
+ * ### Request versioning and concurrency
87
+ *
88
+ * Each poll increments an internal version counter. Only the latest version updates shared state,
89
+ * preventing stale results from overwriting fresh data. `maxInFlightRequests` limits concurrent
90
+ * polls; values > 1 allow the next poll to begin even if aborted requests are still settling, while
91
+ * still capping total concurrency to protect upstream services.
92
+ *
93
+ * ### Debouncing
94
+ *
95
+ * Use `debounce` to accumulate rapid argument changes. The status still transitions to `'stale'`
96
+ * immediately, all running polls are aborted, and the new poll waits for the debounce window
97
+ * (and the timing constraints) before executing.
98
+ *
99
+ * ### Options
100
+ *
101
+ * - `minInterval` — required; must be positive. Zero or negative disables polling (`resume()` no-op). Accepts refs.
102
+ * - `minDelay` — optional delay after completion before the next poll may start. Accepts refs.
103
+ * - `autoStart` — start in active mode (default `true`).
104
+ * - `triggerOnResume` — run the callback immediately on `resume()` (default `false`).
105
+ * - `pauseOnError` — automatically pauses when the callback throws (default `false`).
106
+ * - `maxInFlightRequests` — maximum concurrent polls (default `1`).
107
+ * - `debounce` — debounce window for argument changes in milliseconds (default `0`).
108
+ *
109
+ * ### Returns
110
+ *
111
+ * - `data` — readonly ref of `{ status, value }`.
112
+ * - `lastError` — readonly ref of the latest error (or `null`).
113
+ * - `isActive` — readonly ref indicating active polling.
114
+ * - `inFlightCount` — readonly ref with the number of active requests.
115
+ * - `pause()` and `resume()` controls.
116
+ *
117
+ * @typeParam Args - Arguments shape passed to the polling callback.
118
+ * @typeParam Result - Result type produced by the polling callback.
119
+ */
120
+ export function usePollingQuery<Args, Result>(
121
+ args: WatchSource<Args>,
122
+ queryFn: (args: Args, options: { signal: AbortSignal; pause: () => void }) => Promise<Result>,
123
+ options: {
124
+ minInterval: MaybeRef<number>;
125
+ minDelay?: MaybeRef<number | undefined>;
126
+ autoStart?: boolean;
127
+ triggerOnResume?: boolean;
128
+ pauseOnError?: boolean;
129
+ maxInFlightRequests?: number;
130
+ debounce?: number;
131
+ },
132
+ ) {
133
+ const internal: InternalOptions = {
134
+ minInterval: options.minInterval,
135
+ minDelay: options.minDelay ?? 0,
136
+ autoStart: options.autoStart ?? true,
137
+ triggerOnResume: options.triggerOnResume ?? false,
138
+ pauseOnError: options.pauseOnError ?? false,
139
+ maxInFlightRequests: Math.max(1, options.maxInFlightRequests ?? 1),
140
+ debounce: Math.max(0, options.debounce ?? 0),
141
+ };
142
+
143
+ const resolveMinInterval = () => Math.max(0, toValue(internal.minInterval));
144
+ const resolveMinDelay = () => {
145
+ const raw = internal.minDelay === undefined ? undefined : toValue(internal.minDelay);
146
+ return Math.max(0, raw ?? 0);
147
+ };
148
+ const canRun = () => resolveMinInterval() > 0;
149
+
150
+ const data = shallowRef<PollingData<Result>>({ status: 'idle' });
151
+ const lastError = ref<Error | null>(null);
152
+ const isActive = ref(false);
153
+
154
+ let latestVersion = 0;
155
+ let argsVersion = 0;
156
+ const inFlightCount = ref(0);
157
+ let disposed = false;
158
+
159
+ let scheduledTimeout: ReturnType<typeof setTimeout> | null = null;
160
+ let debounceTimeout: ReturnType<typeof setTimeout> | null = null;
161
+
162
+ let nextMinIntervalStart = 0;
163
+ let nextMinDelayStart = 0;
164
+
165
+ const controllers = new Map<number, AbortController>();
166
+ const abortReasons = new Map<number, AbortReason>();
167
+ const waiters: Waiter[] = [];
168
+
169
+ let currentArgs: Args;
170
+ let hasCurrentArgs = false;
171
+
172
+ const setCurrentArgs = (value: Args) => {
173
+ currentArgs = value;
174
+ hasCurrentArgs = true;
175
+ };
176
+
177
+ const markStale = () => {
178
+ if (data.value.status === 'synced' || data.value.status === 'stale') {
179
+ const { value } = data.value;
180
+ data.value = { status: 'stale', value };
181
+ }
182
+ };
183
+
184
+ const scheduleWaiters = () => {
185
+ if (waiters.length === 0) return;
186
+ const waiter = waiters.shift();
187
+ waiter?.resolve();
188
+ };
189
+
190
+ const waitForSlot = async () => {
191
+ if (inFlightCount.value < internal.maxInFlightRequests) return;
192
+ await new Promise<void>((resolve) => {
193
+ waiters.push({ resolve });
194
+ });
195
+ };
196
+
197
+ const clearScheduled = () => {
198
+ if (scheduledTimeout !== null) {
199
+ clearTimeout(scheduledTimeout);
200
+ scheduledTimeout = null;
201
+ }
202
+ };
203
+
204
+ const clearDebounce = () => {
205
+ if (debounceTimeout !== null) {
206
+ clearTimeout(debounceTimeout);
207
+ debounceTimeout = null;
208
+ }
209
+ };
210
+
211
+ const abortAll = (reason: AbortReason) => {
212
+ controllers.forEach((controller, version) => {
213
+ if (!controller.signal.aborted) {
214
+ abortReasons.set(version, reason);
215
+ controller.abort();
216
+ }
217
+ });
218
+ };
219
+
220
+ const computeDelay = (requestedDelay = 0) => {
221
+ const now = Date.now();
222
+ const earliest = Math.max(nextMinIntervalStart, nextMinDelayStart);
223
+ const baseDelay = earliest > now ? earliest - now : 0;
224
+ return Math.max(0, requestedDelay, baseDelay);
225
+ };
226
+
227
+ const queueExecution = (requestedDelay = 0, source: ScheduleSource = ScheduleSource.Normal) => {
228
+ if (!isActive.value || !canRun() || disposed) return;
229
+ const delay = computeDelay(requestedDelay);
230
+
231
+ if (scheduledTimeout !== null) {
232
+ clearTimeout(scheduledTimeout);
233
+ }
234
+
235
+ scheduledTimeout = setTimeout(() => {
236
+ scheduledTimeout = null;
237
+ void runExecution(source);
238
+ }, delay);
239
+ };
240
+
241
+ const runExecution = async (source: ScheduleSource) => {
242
+ if (!isActive.value || disposed || !canRun()) return;
243
+
244
+ const now = Date.now();
245
+ const earliest = Math.max(nextMinIntervalStart, nextMinDelayStart);
246
+ if (now < earliest) {
247
+ queueExecution(earliest - now, source);
248
+ return;
249
+ }
250
+
251
+ if (!hasCurrentArgs) return;
252
+
253
+ const argsSnapshot = currentArgs;
254
+ const assignedArgsVersion = argsVersion;
255
+
256
+ await waitForSlot();
257
+
258
+ if (!isActive.value || disposed || !canRun()) {
259
+ scheduleWaiters();
260
+ return;
261
+ }
262
+
263
+ const controller = new AbortController();
264
+ const version = ++latestVersion;
265
+
266
+ controllers.set(version, controller);
267
+ inFlightCount.value += 1;
268
+
269
+ const minInterval = resolveMinInterval();
270
+ const startTime = Date.now();
271
+ nextMinIntervalStart = Math.max(nextMinIntervalStart, startTime + minInterval);
272
+
273
+ let pausedByCallback = false;
274
+
275
+ const pauseFromCallback = () => {
276
+ if (pausedByCallback) return;
277
+ pausedByCallback = true;
278
+ pause();
279
+ };
280
+
281
+ try {
282
+ const result = await queryFn(argsSnapshot, { signal: controller.signal, pause: pauseFromCallback });
283
+ if (!controller.signal.aborted) {
284
+ if (version === latestVersion && assignedArgsVersion === argsVersion) {
285
+ lastError.value = null;
286
+ data.value = { status: 'synced', value: result };
287
+ }
288
+ }
289
+ } catch (error) {
290
+ if (controller.signal.aborted) {
291
+ // ignore abort errors
292
+ } else {
293
+ if (version === latestVersion) {
294
+ lastError.value = toError(error);
295
+ }
296
+
297
+ if (internal.pauseOnError) {
298
+ pause();
299
+ }
300
+ }
301
+ } finally {
302
+ const minDelay = resolveMinDelay();
303
+ const finishTime = Date.now();
304
+ nextMinDelayStart = Math.max(nextMinDelayStart, finishTime + minDelay);
305
+
306
+ controllers.delete(version);
307
+ inFlightCount.value = Math.max(0, inFlightCount.value - 1);
308
+ scheduleWaiters();
309
+
310
+ const reason = abortReasons.get(version);
311
+ if (reason) {
312
+ abortReasons.delete(version);
313
+ }
314
+
315
+ const shouldSchedule
316
+ = isActive.value && !disposed && reason !== 'args';
317
+
318
+ if (shouldSchedule) {
319
+ queueExecution();
320
+ }
321
+ }
322
+ };
323
+
324
+ const triggerExecution = (source: ScheduleSource = ScheduleSource.External) => {
325
+ if (!isActive.value || disposed || !canRun()) return;
326
+ queueExecution(0, source);
327
+ };
328
+
329
+ const handleArgsChange = () => {
330
+ argsVersion += 1;
331
+ markStale();
332
+ abortAll('args');
333
+
334
+ if (!isActive.value || !canRun()) {
335
+ return;
336
+ }
337
+
338
+ const schedule = () => {
339
+ triggerExecution(ScheduleSource.External);
340
+ };
341
+
342
+ if (internal.debounce > 0) {
343
+ clearDebounce();
344
+ debounceTimeout = setTimeout(() => {
345
+ debounceTimeout = null;
346
+ schedule();
347
+ }, internal.debounce);
348
+ } else {
349
+ schedule();
350
+ }
351
+ };
352
+
353
+ const pause = () => {
354
+ if (!isActive.value) return;
355
+ isActive.value = false;
356
+ clearScheduled();
357
+ clearDebounce();
358
+ abortAll('pause');
359
+ nextMinIntervalStart = Date.now();
360
+ nextMinDelayStart = Date.now();
361
+ };
362
+
363
+ const resume = () => {
364
+ if (!canRun()) return;
365
+ if (isActive.value) return;
366
+ if (!hasCurrentArgs) return;
367
+ isActive.value = true;
368
+ lastError.value = null;
369
+
370
+ const now = Date.now();
371
+ nextMinIntervalStart = now;
372
+ nextMinDelayStart = now;
373
+
374
+ if (internal.triggerOnResume) {
375
+ triggerExecution(ScheduleSource.External);
376
+ } else {
377
+ queueExecution(resolveMinInterval(), ScheduleSource.External);
378
+ }
379
+ };
380
+
381
+ onScopeDispose(() => {
382
+ disposed = true;
383
+ clearScheduled();
384
+ clearDebounce();
385
+ abortAll('dispose');
386
+ isActive.value = false;
387
+ waiters.splice(0, waiters.length).forEach(({ resolve }) => resolve());
388
+ });
389
+
390
+ watch(
391
+ args,
392
+ (value) => {
393
+ const initial = !hasCurrentArgs;
394
+ setCurrentArgs(value);
395
+ if (initial) {
396
+ return;
397
+ }
398
+ handleArgsChange();
399
+ },
400
+ { flush: 'sync', immediate: true },
401
+ );
402
+
403
+ if (internal.autoStart && canRun()) {
404
+ resume();
405
+ }
406
+
407
+ return {
408
+ data: readonly(data),
409
+ lastError: readonly(lastError),
410
+ isActive: readonly(isActive),
411
+ inFlightCount: readonly(inFlightCount),
412
+ pause,
413
+ resume,
414
+ };
415
+ }
package/src/index.ts CHANGED
@@ -103,6 +103,7 @@ export { useEventListener } from './composition/useEventListener';
103
103
  export { useFormState } from './composition/useFormState';
104
104
  export { useHover } from './composition/useHover';
105
105
  export { useInterval } from './composition/useInterval';
106
+ export { usePollingQuery } from './composition/usePollingQuery';
106
107
  export { useLocalStorage } from './composition/useLocalStorage';
107
108
  export { useMouse } from './composition/useMouse';
108
109
  export { useMouseCapture } from './composition/useMouseCapture';