Skip to content

Commit 78cb448

Browse files
author
Pascal Klesse
committed
refactor: replace useIntervalFn with sequential usePolling composable
Add usePolling composable that waits for previous request completion before scheduling next interval, preventing request stacking during slow server responses. Migrate all polling instances from useIntervalFn to usePolling with error handling and auto-pause capabilities.
1 parent 4b629bd commit 78cb448

File tree

10 files changed

+197
-46
lines changed

10 files changed

+197
-46
lines changed

projects/app/src/components/Modals/ModalBackupLog.vue

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,7 @@ const { data, refresh } = await useAsyncGetBackupByDatabaseQuery(
1515
);
1616
const log = computed<string[]>(() => data.value?.log || []);
1717
18-
const { pause } = useIntervalFn(() => {
19-
refresh();
20-
}, 4000);
21-
22-
onBeforeUnmount(() => {
23-
pause();
24-
});
18+
usePolling(refresh, { interval: 4000 });
2519
</script>
2620

2721
<template>

projects/app/src/components/Modals/ModalRestoreLog.vue

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,7 @@ const { data, refresh } = await useAsyncGetBackupByDatabaseQuery(
1515
);
1616
const log = computed<string[]>(() => data.value?.restoreLog || []);
1717
18-
const { pause } = useIntervalFn(() => {
19-
refresh();
20-
}, 4000);
21-
22-
onBeforeUnmount(() => {
23-
pause();
24-
});
18+
usePolling(refresh, { interval: 4000 });
2519
</script>
2620

2721
<template>
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import type { PollingOptions, PollingReturn } from '~/interfaces/polling.interface';
2+
3+
/**
4+
* Composable for sequential polling that waits for the previous request to complete
5+
* before starting the next interval timer.
6+
*
7+
* Unlike useIntervalFn, this ensures requests don't stack up when the server
8+
* is slow or network latency is high.
9+
*
10+
* @param fn - Async function to poll (must return a Promise)
11+
* @param options - Polling configuration
12+
* @returns Control methods and state refs
13+
*
14+
* @example
15+
* ```typescript
16+
* const { data, refresh } = await useAsyncGetContainerQuery({ id: containerId.value }, null);
17+
*
18+
* const { pause, resume, isActive, isPending } = usePolling(refresh, {
19+
* interval: 2000,
20+
* onError: (error) => console.error('Polling error:', error),
21+
* });
22+
* ```
23+
*/
24+
export function usePolling(fn: () => Promise<unknown>, options: PollingOptions): PollingReturn {
25+
// ============================================================================
26+
// Variables
27+
// ============================================================================
28+
const { immediate = true, interval, maxConsecutiveErrors = 3, onError } = options;
29+
30+
const errorCount = ref<number>(0);
31+
const isActive = ref<boolean>(immediate);
32+
const isPending = ref<boolean>(false);
33+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
34+
35+
// ============================================================================
36+
// Functions
37+
// ============================================================================
38+
39+
/**
40+
* Schedule the next polling execution
41+
*/
42+
function scheduleNext(): void {
43+
if (timeoutId) {
44+
clearTimeout(timeoutId);
45+
}
46+
47+
timeoutId = setTimeout(() => {
48+
execute();
49+
}, interval);
50+
}
51+
52+
/**
53+
* Execute the polling function and schedule the next call
54+
*/
55+
async function execute(): Promise<void> {
56+
if (isPending.value || !isActive.value) {
57+
return;
58+
}
59+
60+
isPending.value = true;
61+
62+
try {
63+
await fn();
64+
errorCount.value = 0;
65+
} catch (error) {
66+
errorCount.value++;
67+
68+
if (onError && error instanceof Error) {
69+
onError(error);
70+
}
71+
72+
// Auto-pause after too many consecutive errors
73+
if (errorCount.value >= maxConsecutiveErrors) {
74+
console.warn(`[usePolling] Paused after ${maxConsecutiveErrors} consecutive errors`);
75+
isActive.value = false;
76+
}
77+
} finally {
78+
isPending.value = false;
79+
80+
if (isActive.value) {
81+
scheduleNext();
82+
}
83+
}
84+
}
85+
86+
/**
87+
* Pause polling
88+
*/
89+
function pause(): void {
90+
isActive.value = false;
91+
if (timeoutId) {
92+
clearTimeout(timeoutId);
93+
timeoutId = null;
94+
}
95+
}
96+
97+
/**
98+
* Resume polling
99+
*/
100+
function resume(): void {
101+
if (isActive.value) {
102+
return;
103+
}
104+
105+
isActive.value = true;
106+
errorCount.value = 0;
107+
scheduleNext();
108+
}
109+
110+
/**
111+
* Manually trigger the polling function once (outside of regular interval)
112+
*/
113+
async function trigger(): Promise<void> {
114+
if (isPending.value) {
115+
return;
116+
}
117+
118+
if (timeoutId) {
119+
clearTimeout(timeoutId);
120+
timeoutId = null;
121+
}
122+
123+
await execute();
124+
}
125+
126+
// ============================================================================
127+
// Lifecycle
128+
// ============================================================================
129+
130+
if (immediate) {
131+
scheduleNext();
132+
}
133+
134+
onBeforeUnmount(() => {
135+
pause();
136+
});
137+
138+
// ============================================================================
139+
// Return
140+
// ============================================================================
141+
return {
142+
errorCount: readonly(errorCount),
143+
isActive: readonly(isActive),
144+
isPending: readonly(isPending),
145+
pause,
146+
resume,
147+
trigger,
148+
};
149+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { Ref } from 'vue';
2+
3+
/**
4+
* Options for the usePolling composable
5+
*/
6+
export interface PollingOptions {
7+
/** Whether polling should start immediately (default: true) */
8+
immediate?: boolean;
9+
/** Delay in milliseconds between completed request and next request start */
10+
interval: number;
11+
/** Maximum number of consecutive errors before auto-pause (default: 3) */
12+
maxConsecutiveErrors?: number;
13+
/** Callback when an error occurs during polling */
14+
onError?: (error: Error) => void;
15+
}
16+
17+
/**
18+
* Return type of the usePolling composable
19+
*/
20+
export interface PollingReturn {
21+
/** Number of consecutive errors */
22+
errorCount: Readonly<Ref<number>>;
23+
/** Whether polling is currently active */
24+
isActive: Readonly<Ref<boolean>>;
25+
/** Whether a request is currently pending */
26+
isPending: Readonly<Ref<boolean>>;
27+
/** Pause polling */
28+
pause: () => void;
29+
/** Resume polling */
30+
resume: () => void;
31+
/** Manually trigger the polling function once (outside of interval) */
32+
trigger: () => Promise<void>;
33+
}

projects/app/src/pages/index.vue

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,7 @@ const { open } = useModal();
4646
const { open: openMenu } = useContextMenu();
4747
const { currentUserState: user } = useAuthState();
4848
49-
useIntervalFn(async () => {
50-
await refresh();
51-
}, 2000);
49+
usePolling(refresh, { interval: 2000 });
5250
5351
onMounted(() => {
5452
const expandedStorage = useLocalStorage<string[]>('expanded', []);

projects/app/src/pages/projects/[id]/[containerId].vue

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,7 @@ if (container.value?.healthCheckCmd || container.value?.customDockerfile?.includ
8888
healthyState = computed(() => healthData.value);
8989
}
9090
91-
useIntervalFn(async () => {
92-
await refresh();
93-
}, 2000);
91+
usePolling(refresh, { interval: 2000 });
9492
9593
onMounted(async () => {
9694
if (process.client) {

projects/app/src/pages/projects/[id]/[containerId]/builds.vue

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,14 @@ const { data: buildData, refresh: refreshBuilds } = await useAsyncFindBuildsForC
2323
);
2424
const builds = computed(() => buildData.value || []);
2525
26-
const { pause } = useIntervalFn(() => {
27-
refreshBuilds();
28-
}, 2000);
26+
usePolling(refreshBuilds, { interval: 2000 });
2927
3028
onMounted(async () => {
3129
if (builds.value?.length) {
3230
await navigateTo(`/projects/${projectId.value}/${containerId.value}/builds/${builds.value[0].id}`);
3331
}
3432
});
3533
36-
onBeforeUnmount(() => {
37-
pause();
38-
});
39-
4034
function timeAgo(date: string) {
4135
return useTimeAgo(new Date(date)).value;
4236
}

projects/app/src/pages/projects/[id]/[containerId]/builds/[buildId].vue

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,14 @@ const buildId = ref<string>(route.params.buildId ? (route.params.buildId as stri
1616
const { data: buildData, refresh: refreshBuild } = await useAsyncGetBuildQuery({ id: buildId.value }, null);
1717
const build = computed(() => buildData.value || null);
1818
19-
const { pause } = useIntervalFn(() => {
20-
if (build) {
21-
refreshBuild();
22-
}
23-
}, 2000);
24-
25-
onBeforeUnmount(() => {
26-
pause();
27-
});
19+
usePolling(
20+
async () => {
21+
if (build.value) {
22+
await refreshBuild();
23+
}
24+
},
25+
{ interval: 2000 },
26+
);
2827
</script>
2928

3029
<template>

projects/app/src/pages/projects/[id]/[containerId]/logs.vue

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,7 @@ const containerId = ref<string>(route.params.containerId ? (route.params.contain
1515
const { data: logData, refresh: refreshLogs } = await useAsyncGetContainerQuery({ id: containerId.value }, ['logs']);
1616
const logs = computed(() => (logData.value?.logs as string[]) || null);
1717
18-
const { pause } = useIntervalFn(() => {
19-
refreshLogs();
20-
}, 5000);
21-
22-
onBeforeUnmount(() => {
23-
pause();
24-
});
18+
usePolling(refreshLogs, { interval: 5000 });
2519
</script>
2620

2721
<template>

projects/app/src/pages/projects/[id]/[containerId]/stats.vue

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,7 @@ const containerId = ref<string>(route.params.containerId ? (route.params.contain
1515
const { data, refresh } = await useAsyncGetContainerStatsQuery({ id: containerId.value }, null, true);
1616
const stats = computed(() => data?.value || null);
1717
18-
useIntervalFn(() => {
19-
refresh();
20-
}, 5000);
18+
usePolling(refresh, { interval: 5000 });
2119
</script>
2220

2321
<template>

0 commit comments

Comments
 (0)