diff --git a/frontend/src/pages/Scans.tsx b/frontend/src/pages/Scans.tsx index 6b5046ff..5898d4c0 100644 --- a/frontend/src/pages/Scans.tsx +++ b/frontend/src/pages/Scans.tsx @@ -66,6 +66,8 @@ export default function Scans() { // Ref so the visibilitychange handler always sees the current interval id const intervalRef = useRef | null>(null); + const requestSeqRef = useRef(0); + const abortRef = useRef(null); function startPolling() { stopPolling(); @@ -96,27 +98,45 @@ export default function Scans() { return () => { stopPolling(); + abortRef.current?.abort(); document.removeEventListener("visibilitychange", handleVisibilityChange); }; }, [filter, page]); async function loadTasks() { + const requestSeq = requestSeqRef.current + 1; + requestSeqRef.current = requestSeq; + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + try { const params = new URLSearchParams(); if (filter !== "all") params.set("status", filter); params.set("page", String(page)); params.set("per_page", String(PAGE_LIMIT)); - const res = await fetch(`${API_BASE}/tasks?${params.toString()}`); + const res = await fetch(`${API_BASE}/tasks?${params.toString()}`, { + signal: controller.signal, + }); + if (!res.ok) { + throw new Error(`Failed to load tasks: ${res.status}`); + } const data = await res.json(); + if (requestSeq !== requestSeqRef.current) return; + setTasks(data.tasks || []); if (data.pagination?.total_items !== undefined) { setTotal(data.pagination.total_items); } } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; console.error("Failed to load tasks:", err); } finally { - setLoading(false); + if (requestSeq === requestSeqRef.current) { + abortRef.current = null; + setLoading(false); + } } } diff --git a/frontend/testing/unit/pages/Scans.polling.test.tsx b/frontend/testing/unit/pages/Scans.polling.test.tsx index ef0a841a..2884ec71 100644 --- a/frontend/testing/unit/pages/Scans.polling.test.tsx +++ b/frontend/testing/unit/pages/Scans.polling.test.tsx @@ -1,4 +1,4 @@ -import { render, act } from '@testing-library/react'; +import { render, act, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import Scans from '../../../src/pages/Scans'; @@ -18,11 +18,34 @@ vi.mock('react-router-dom', async (importOriginal) => { }); const EMPTY_RESPONSE = { tasks: [], pagination: { total_items: 0 } }; +const LATEST_RESPONSE = { + tasks: [{ + task_id: 'latest-task', + plugin_id: 'nmap', + tool: 'Latest Tool', + target: 'latest.example.com', + status: 'completed', + created_at: '2026-05-29T10:00:00Z', + }], + pagination: { total_items: 1 }, +}; +const STALE_RESPONSE = { + tasks: [{ + task_id: 'stale-task', + plugin_id: 'nmap', + tool: 'Stale Tool', + target: 'stale.example.com', + status: 'completed', + created_at: '2026-05-29T09:00:00Z', + }], + pagination: { total_items: 1 }, +}; let fetchSpy: ReturnType; beforeEach(() => { fetchSpy = vi.fn().mockResolvedValue({ + ok: true, json: () => Promise.resolve(EMPTY_RESPONSE), }); vi.stubGlobal('fetch', fetchSpy); @@ -77,6 +100,20 @@ async function tickTime(ms: number) { }); } +function deferredResponse(body: unknown) { + let resolve!: (value: Response) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { + promise, + resolve: () => resolve({ + ok: true, + json: () => Promise.resolve(body), + } as Response), + }; +} + // ── Tests ──────────────────────────────────────────────────────────────────── describe('Scans — visibility-aware polling', () => { @@ -153,4 +190,39 @@ describe('Scans — visibility-aware polling', () => { expect(fetchSpy).toHaveBeenCalledTimes(callsAfterMount); expect(removeSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); }); + + it('ignores stale task responses when a newer poll finishes first', async () => { + const stale = deferredResponse(STALE_RESPONSE); + const latest = deferredResponse(LATEST_RESPONSE); + fetchSpy.mockReset(); + fetchSpy.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(EMPTY_RESPONSE), + }); + fetchSpy + .mockReturnValueOnce(stale.promise) + .mockReturnValueOnce(latest.promise); + + renderScans(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + await tickTime(5_000); + expect(fetchSpy).toHaveBeenCalledTimes(2); + + await act(async () => { + latest.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByText('Latest Tool')).toBeInTheDocument(); + + await act(async () => { + stale.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(screen.getByText('Latest Tool')).toBeInTheDocument(); + expect(screen.queryByText('Stale Tool')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/testing/unit/pages/ScansPhases.test.tsx b/frontend/testing/unit/pages/ScansPhases.test.tsx index a53d116a..c14bead5 100644 --- a/frontend/testing/unit/pages/ScansPhases.test.tsx +++ b/frontend/testing/unit/pages/ScansPhases.test.tsx @@ -74,6 +74,7 @@ describe('Scans — phase display', () => { vi.useFakeTimers(); fetchSpy = vi.fn().mockResolvedValue({ + ok: true, json: () => Promise.resolve(RUNNING_WITH_PHASE_RESPONSE), }); vi.stubGlobal('fetch', fetchSpy); @@ -107,6 +108,7 @@ describe('Scans — phase display', () => { it('does not show phase for queued task', async () => { fetchSpy.mockResolvedValue({ + ok: true, json: () => Promise.resolve(QUEUED_RESPONSE), }); @@ -125,6 +127,7 @@ describe('Scans — phase display', () => { await flush(); fetchSpy.mockResolvedValueOnce({ + ok: true, json: () => Promise.resolve({ tasks: [makeTask('task-1', 'running', 'parsing')],