diff --git a/Docs/Tasks.md b/Docs/Tasks.md index eaf1a1c..0e3d450 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -205,7 +205,7 @@ Issues are grouped by category and ordered roughly by severity. Each entry descr --- -### TASK-011 — No `React.memo` on any heavy component +### TASK-011 — No `React.memo` on any heavy component (done) **Where found:** Every component in `frontend/src/components/` — zero uses of `React.memo` exist in the codebase. @@ -222,7 +222,9 @@ Issues are grouped by category and ordered roughly by severity. Each entry descr --- -### TASK-012 — `useMapData` sets `loading=true` before the debounce fires +### TASK-012 — `useMapData` sets `loading=true` before the debounce fires (done) + +**Where fixed:** `frontend/src/hooks/useMapData.ts`, `frontend/src/hooks/__tests__/useMapData.test.ts` **Where found:** `frontend/src/hooks/useMapData.ts`, `load` callback — `setLoading(true)` is called at the top of `load`, but the actual fetch is deferred inside a `setTimeout` of 300 ms. diff --git a/frontend/src/hooks/__tests__/useMapData.test.ts b/frontend/src/hooks/__tests__/useMapData.test.ts new file mode 100644 index 0000000..58229f4 --- /dev/null +++ b/frontend/src/hooks/__tests__/useMapData.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import type { BansByCountryResponse } from "../../types/map"; +import { useMapData } from "../useMapData"; +import * as api from "../../api/map"; + +vi.mock("../../api/map"); + +describe("useMapData", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("delays loading until the debounced fetch begins", async () => { + const fetchMock = vi.mocked(api.fetchBansByCountry); + const firstResponse: BansByCountryResponse = { + countries: { US: 1 }, + country_names: { US: "United States" }, + bans: [], + total: 1, + }; + const secondResponse: BansByCountryResponse = { + countries: { US: 2 }, + country_names: { US: "United States" }, + bans: [], + total: 2, + }; + + let secondResolve: (value: BansByCountryResponse) => void; + fetchMock.mockResolvedValueOnce(firstResponse); + fetchMock.mockImplementationOnce( + () => + new Promise((resolve) => { + secondResolve = resolve; + }), + ); + + const { result } = renderHook(() => useMapData("24h", "all", "fail2ban")); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 310)); + }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + + act(() => { + result.current.refresh(); + }); + + expect(result.current.loading).toBe(false); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + expect(result.current.loading).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + }); + + expect(result.current.loading).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + + await act(async () => { + secondResolve(secondResponse); + await Promise.resolve(); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.total).toBe(2); + }); +}); diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts index a93bdad..644b2ed 100644 --- a/frontend/src/hooks/useMapData.ts +++ b/frontend/src/hooks/useMapData.ts @@ -57,11 +57,11 @@ export function useMapData( if (debounceRef.current != null) { clearTimeout(debounceRef.current); } - // Show loading immediately so the skeleton / spinner appears. - setLoading(true); setError(null); debounceRef.current = setTimeout((): void => { + // Show loading only when the fetch is about to start. + setLoading(true); // Abort any in-flight request from a previous filter selection. abortRef.current?.abort(); abortRef.current = new AbortController();