fix(frontend): deduplicate setup status API calls using shared hook
Implement request deduplication to prevent multiple duplicate calls to GET /api/setup when multiple components mount simultaneously. The fix introduces: 1. New 'useSharedSetupStatus' hook with module-level caching - Shares a single in-flight request across all consumers - Implements 30-second cache TTL with cache invalidation - Notifies all subscribers when cache is invalidated 2. Refactored 'useSetup' hook to use shared cache - Internally uses useSharedSetupStatus for status checks - Calls invalidateSetupStatus() after successful setup submission - Maintains backward-compatible API 3. Updated components using setup status - SetupGuard and SetupPage automatically benefit from deduplication - No changes needed to consumer code 4. Updated tests - Mocked useSharedSetupStatus in component tests - Added comprehensive tests for cache behavior - All existing tests pass 5. Documentation updates - Added 'Request Deduplication & Shared Caching' section to Web-Development.md - Explains when and how to use shared hooks - Provides complete implementation example This eliminates wasted resources from duplicate API calls and potential race conditions where different requests return slightly different states. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -4,14 +4,15 @@ import { MemoryRouter, Routes, Route } from "react-router-dom";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { SetupGuard } from "../SetupGuard";
|
||||
|
||||
// Mock the setup API module so tests never hit a real network.
|
||||
vi.mock("../../api/setup", () => ({
|
||||
getSetupStatus: vi.fn(),
|
||||
// Mock the shared setup status hook
|
||||
vi.mock("../../hooks/useSharedSetupStatus", () => ({
|
||||
useSharedSetupStatus: vi.fn(),
|
||||
invalidateSetupStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getSetupStatus } from "../../api/setup";
|
||||
import { useSharedSetupStatus } from "../../hooks/useSharedSetupStatus";
|
||||
|
||||
const mockedGetSetupStatus = vi.mocked(getSetupStatus);
|
||||
const mockedUseSharedSetupStatus = vi.mocked(useSharedSetupStatus);
|
||||
|
||||
function renderGuard() {
|
||||
return render(
|
||||
@@ -42,14 +43,23 @@ describe("SetupGuard", () => {
|
||||
});
|
||||
|
||||
it("shows a spinner while the setup status is loading", () => {
|
||||
// getSetupStatus resolves eventually — spinner should show immediately.
|
||||
mockedGetSetupStatus.mockReturnValue(new Promise(() => {}));
|
||||
mockedUseSharedSetupStatus.mockReturnValue({
|
||||
status: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
renderGuard();
|
||||
expect(screen.getByRole("progressbar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders children when setup is complete", async () => {
|
||||
mockedGetSetupStatus.mockResolvedValue({ completed: true });
|
||||
mockedUseSharedSetupStatus.mockReturnValue({
|
||||
status: { completed: true },
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
renderGuard();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("protected-content")).toBeInTheDocument();
|
||||
@@ -57,7 +67,12 @@ describe("SetupGuard", () => {
|
||||
});
|
||||
|
||||
it("redirects to /setup when setup is not complete", async () => {
|
||||
mockedGetSetupStatus.mockResolvedValue({ completed: false });
|
||||
mockedUseSharedSetupStatus.mockReturnValue({
|
||||
status: { completed: false },
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
renderGuard();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("setup-page")).toBeInTheDocument();
|
||||
@@ -66,7 +81,12 @@ describe("SetupGuard", () => {
|
||||
});
|
||||
|
||||
it("renders an error card when the setup status check fails", async () => {
|
||||
mockedGetSetupStatus.mockRejectedValue(new Error("Network error"));
|
||||
mockedUseSharedSetupStatus.mockReturnValue({
|
||||
status: null,
|
||||
loading: false,
|
||||
error: "Network error",
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
renderGuard();
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -78,8 +98,13 @@ describe("SetupGuard", () => {
|
||||
});
|
||||
|
||||
it("retries setup status fetch when Retry is clicked", async () => {
|
||||
mockedGetSetupStatus.mockRejectedValueOnce(new Error("Network error"));
|
||||
mockedGetSetupStatus.mockResolvedValueOnce({ completed: true });
|
||||
const refreshMock = vi.fn();
|
||||
mockedUseSharedSetupStatus.mockReturnValue({
|
||||
status: null,
|
||||
loading: false,
|
||||
error: "Network error",
|
||||
refresh: refreshMock,
|
||||
});
|
||||
|
||||
renderGuard();
|
||||
|
||||
@@ -89,8 +114,6 @@ describe("SetupGuard", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("protected-content")).toBeInTheDocument();
|
||||
});
|
||||
expect(refreshMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user