From 78fe639540b02dec0ce931c5fe1ab9a7a7e54da1 Mon Sep 17 00:00:00 2001
From: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Date: Tue, 24 Feb 2026 06:27:24 +0100
Subject: [PATCH] feat: periodically re-poll queue progress state (#9136)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
- Add `useQueuePolling` composable that polls `queueStore.update()`
every 5s while jobs are active
- Calls `update()` immediately on creation so the UI is current after
page reload
- Uses `useIntervalFn` + `watch` pattern (same as `assetDownloadStore`)
to pause/resume based on `activeJobsCount`
## Related Issue
- Related to #8136
## QA
- Queue a prompt, reload page mid-execution, verify queue UI updates
every ~5s
- Verify polling stops when queue empties
- Verify polling resumes when new jobs are queued
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9136-feat-periodically-re-poll-queue-progress-state-3106d73d36508119a32fc5b9c8eda21c)
by [Unito](https://www.unito.io)
---
.../remote/comfyui/useQueuePolling.test.ts | 173 ++++++++++++++++++
.../remote/comfyui/useQueuePolling.ts | 47 +++++
src/views/GraphView.vue | 2 +
3 files changed, 222 insertions(+)
create mode 100644 src/platform/remote/comfyui/useQueuePolling.test.ts
create mode 100644 src/platform/remote/comfyui/useQueuePolling.ts
diff --git a/src/platform/remote/comfyui/useQueuePolling.test.ts b/src/platform/remote/comfyui/useQueuePolling.test.ts
new file mode 100644
index 0000000000..c27c8f9565
--- /dev/null
+++ b/src/platform/remote/comfyui/useQueuePolling.test.ts
@@ -0,0 +1,173 @@
+import { mount } from '@vue/test-utils'
+import type { VueWrapper } from '@vue/test-utils'
+import { nextTick, reactive } from 'vue'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { useQueuePolling } from '@/platform/remote/comfyui/useQueuePolling'
+
+vi.mock('@/stores/queueStore', () => {
+ const state = reactive({
+ activeJobsCount: 0,
+ isLoading: false,
+ update: vi.fn()
+ })
+
+ return {
+ useQueueStore: () => state
+ }
+})
+
+// Re-import to get the mock instance for assertions
+import { useQueueStore } from '@/stores/queueStore'
+
+function mountUseQueuePolling() {
+ return mount({
+ template: '
',
+ setup() {
+ useQueuePolling()
+ return {}
+ }
+ })
+}
+
+describe('useQueuePolling', () => {
+ let wrapper: VueWrapper
+ const store = useQueueStore() as Partial<
+ ReturnType
+ > as {
+ activeJobsCount: number
+ isLoading: boolean
+ update: ReturnType
+ }
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ store.activeJobsCount = 0
+ store.isLoading = false
+ store.update.mockReset()
+ })
+
+ afterEach(() => {
+ wrapper?.unmount()
+ vi.useRealTimers()
+ })
+
+ it('does not call update on creation', () => {
+ wrapper = mountUseQueuePolling()
+ expect(store.update).not.toHaveBeenCalled()
+ })
+
+ it('polls when activeJobsCount is exactly 1', async () => {
+ wrapper = mountUseQueuePolling()
+
+ store.activeJobsCount = 1
+ await vi.advanceTimersByTimeAsync(8_000)
+
+ expect(store.update).toHaveBeenCalledOnce()
+ })
+
+ it('does not poll when activeJobsCount > 1', async () => {
+ wrapper = mountUseQueuePolling()
+
+ store.activeJobsCount = 2
+ await vi.advanceTimersByTimeAsync(16_000)
+
+ expect(store.update).not.toHaveBeenCalled()
+ })
+
+ it('stops polling when activeJobsCount drops to 0', async () => {
+ store.activeJobsCount = 1
+ wrapper = mountUseQueuePolling()
+
+ store.activeJobsCount = 0
+ await vi.advanceTimersByTimeAsync(16_000)
+
+ expect(store.update).not.toHaveBeenCalled()
+ })
+
+ it('resets timer when loading completes', async () => {
+ store.activeJobsCount = 1
+ wrapper = mountUseQueuePolling()
+
+ // Advance 5s toward the 8s timeout
+ await vi.advanceTimersByTimeAsync(5_000)
+ expect(store.update).not.toHaveBeenCalled()
+
+ // Simulate an external update (e.g. WebSocket-triggered) completing
+ store.isLoading = true
+ await nextTick()
+ store.isLoading = false
+ await nextTick()
+
+ // Timer should now be reset — should not fire 3s later (original 8s mark)
+ await vi.advanceTimersByTimeAsync(3_000)
+ expect(store.update).not.toHaveBeenCalled()
+
+ // Should fire 8s after the reset (5s more)
+ await vi.advanceTimersByTimeAsync(5_000)
+ expect(store.update).toHaveBeenCalledOnce()
+ })
+
+ it('applies exponential backoff on successive polls', async () => {
+ store.activeJobsCount = 1
+ wrapper = mountUseQueuePolling()
+
+ // First poll at 8s
+ await vi.advanceTimersByTimeAsync(8_000)
+ expect(store.update).toHaveBeenCalledTimes(1)
+
+ // Simulate update completing to reschedule
+ store.isLoading = true
+ await nextTick()
+ store.isLoading = false
+ await nextTick()
+
+ // Second poll at 12s (8 * 1.5)
+ await vi.advanceTimersByTimeAsync(11_000)
+ expect(store.update).toHaveBeenCalledTimes(1)
+ await vi.advanceTimersByTimeAsync(1_000)
+ expect(store.update).toHaveBeenCalledTimes(2)
+ })
+
+ it('skips poll when an update is already in-flight', async () => {
+ store.activeJobsCount = 1
+ wrapper = mountUseQueuePolling()
+
+ // Simulate an external update starting before the timer fires
+ store.isLoading = true
+
+ await vi.advanceTimersByTimeAsync(8_000)
+ expect(store.update).not.toHaveBeenCalled()
+
+ // Once the in-flight update completes, polling resumes
+ store.isLoading = false
+
+ await vi.advanceTimersByTimeAsync(8_000)
+ expect(store.update).toHaveBeenCalledOnce()
+ })
+
+ it('resets backoff when activeJobsCount changes', async () => {
+ store.activeJobsCount = 1
+ wrapper = mountUseQueuePolling()
+
+ // First poll at 8s (backs off delay to 12s)
+ await vi.advanceTimersByTimeAsync(8_000)
+ expect(store.update).toHaveBeenCalledTimes(1)
+
+ // Simulate update completing
+ store.isLoading = true
+ await nextTick()
+ store.isLoading = false
+ await nextTick()
+
+ // Count changes — backoff should reset to 8s
+ store.activeJobsCount = 0
+ await nextTick()
+ store.activeJobsCount = 1
+ await nextTick()
+
+ store.update.mockClear()
+ await vi.advanceTimersByTimeAsync(8_000)
+ expect(store.update).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/platform/remote/comfyui/useQueuePolling.ts b/src/platform/remote/comfyui/useQueuePolling.ts
new file mode 100644
index 0000000000..c66c14a25a
--- /dev/null
+++ b/src/platform/remote/comfyui/useQueuePolling.ts
@@ -0,0 +1,47 @@
+import { useTimeoutFn } from '@vueuse/core'
+import { ref, watch } from 'vue'
+
+import { useQueueStore } from '@/stores/queueStore'
+
+const BASE_INTERVAL_MS = 8_000
+const MAX_INTERVAL_MS = 32_000
+const BACKOFF_MULTIPLIER = 1.5
+
+export function useQueuePolling() {
+ const queueStore = useQueueStore()
+ const delay = ref(BASE_INTERVAL_MS)
+
+ const { start, stop } = useTimeoutFn(
+ () => {
+ if (queueStore.activeJobsCount !== 1 || queueStore.isLoading) return
+ delay.value = Math.min(delay.value * BACKOFF_MULTIPLIER, MAX_INTERVAL_MS)
+ void queueStore.update()
+ },
+ delay,
+ { immediate: false }
+ )
+
+ function scheduleNextPoll() {
+ if (queueStore.activeJobsCount === 1 && !queueStore.isLoading) start()
+ else stop()
+ }
+
+ watch(
+ () => queueStore.activeJobsCount,
+ () => {
+ delay.value = BASE_INTERVAL_MS
+ scheduleNextPoll()
+ },
+ { immediate: true }
+ )
+
+ // Reschedule after any update completes (whether from polling or
+ // WebSocket events) to avoid redundant requests.
+ watch(
+ () => queueStore.isLoading,
+ (loading, wasLoading) => {
+ if (wasLoading && !loading) scheduleNextPoll()
+ },
+ { flush: 'sync' }
+ )
+}
diff --git a/src/views/GraphView.vue b/src/views/GraphView.vue
index 667a4f0150..ac23f31a9d 100644
--- a/src/views/GraphView.vue
+++ b/src/views/GraphView.vue
@@ -51,6 +51,7 @@ import InviteAcceptedToast from '@/platform/workspace/components/toasts/InviteAc
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
import { useCoreCommands } from '@/composables/useCoreCommands'
+import { useQueuePolling } from '@/platform/remote/comfyui/useQueuePolling'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useProgressFavicon } from '@/composables/useProgressFavicon'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
@@ -209,6 +210,7 @@ useKeybindingService().registerCoreKeybindings()
useSidebarTabStore().registerCoreSidebarTabs()
void useBottomPanelStore().registerCoreBottomPanelTabs()
+useQueuePolling()
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
const sidebarTabStore = useSidebarTabStore()