diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx index ec42f3bcb..ec3a6345b 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx @@ -625,7 +625,9 @@ describe('ImageCanvasTaskSidebarView', () => { }); let activeRequestCount = 0; listExternalGenerationTasksMock.mockImplementation( - (options: Parameters[0] = {}) => { + ( + options: Parameters[0] = {}, + ): ReturnType => { if (options.statuses?.includes('running')) { activeRequestCount += 1; if (activeRequestCount === 1) { @@ -713,6 +715,67 @@ describe('ImageCanvasTaskSidebarView', () => { } }); + it('aborts every bootstrap attempt when one branch fails and after retry unmounts', async () => { + vi.useFakeTimers(); + try { + const requestSignals: AbortSignal[] = []; + let activeRequestCount = 0; + listExternalGenerationTasksMock.mockImplementation( + (options: Parameters[0] = {}) => { + const signal = options.signal; + if (!signal) { + return Promise.reject(new Error('missing abort signal')); + } + requestSignals.push(signal); + if (options.statuses?.includes('running')) { + activeRequestCount += 1; + if (activeRequestCount === 1) { + return Promise.reject(new Error('active bootstrap failed')); + } + } + return new Promise< + Awaited> + >((_, reject) => { + signal.addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true }, + ); + }); + }, + ); + + const { unmount } = render( + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(requestSignals).toHaveLength(2); + expect(requestSignals[0]).toBe(requestSignals[1]); + expect(requestSignals[0]!.aborted).toBe(true); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + await Promise.resolve(); + }); + expect(requestSignals).toHaveLength(4); + expect(requestSignals[2]).toBe(requestSignals[3]); + expect(requestSignals[2]!.aborted).toBe(false); + + unmount(); + expect(requestSignals[2]!.aborted).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + it('loads more completed tasks while scrolling and keeps the request capped', async () => { const completedTasks = Array.from({ length: 120 }, (_, index) => { const completedAt = new Date(Date.now() - index * 1000).toISOString(); diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx index f985283d8..835fb1c5a 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx @@ -362,23 +362,24 @@ export function ImageCanvasTaskSidebarView({ let retryTimerId: number | null = null; let retryIndex = 0; const loadTaskLists = () => { - controller = new AbortController(); + const attemptController = new AbortController(); + controller = attemptController; Promise.all([ listExternalGenerationTasks({ limit: ACTIVE_TASK_LIST_LIMIT, includeAcknowledgedTerminal: false, statuses: ['running', 'queued'], - signal: controller.signal, + signal: attemptController.signal, }), listExternalGenerationTasks({ limit: completedListLimit, includeAcknowledgedTerminal: true, statuses: ['completed', 'failed'], - signal: controller.signal, + signal: attemptController.signal, }), ]) .then(([activeResponse, completedResponse]) => { - if (disposed) { + if (disposed || attemptController.signal.aborted) { return; } const activeTasks = activeResponse.tasks.filter(isActiveExternalTask); @@ -408,6 +409,10 @@ export function ImageCanvasTaskSidebarView({ ); }) .catch(() => { + attemptController.abort(); + if (controller === attemptController) { + controller = null; + } if ( disposed || retryIndex >= TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS.length @@ -417,6 +422,11 @@ export function ImageCanvasTaskSidebarView({ const retryDelayMs = TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS[retryIndex]; retryIndex += 1; retryTimerId = window.setTimeout(loadTaskLists, retryDelayMs); + }) + .finally(() => { + if (controller === attemptController) { + controller = null; + } }); };