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:
@@ -5,15 +5,20 @@ import { MemoryRouter, Routes, Route } from "react-router-dom";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { SetupPage } from "../SetupPage";
|
||||
|
||||
// Mock the setup API so tests never hit a real network.
|
||||
// Mock the shared setup hook and setup API
|
||||
vi.mock("../../hooks/useSharedSetupStatus", () => ({
|
||||
useSharedSetupStatus: vi.fn(),
|
||||
invalidateSetupStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../api/setup", () => ({
|
||||
getSetupStatus: vi.fn(),
|
||||
submitSetup: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getSetupStatus, submitSetup } from "../../api/setup";
|
||||
import { useSharedSetupStatus } from "../../hooks/useSharedSetupStatus";
|
||||
import { submitSetup } from "../../api/setup";
|
||||
|
||||
const mockedGetSetupStatus = vi.mocked(getSetupStatus);
|
||||
const mockedUseSharedSetupStatus = vi.mocked(useSharedSetupStatus);
|
||||
const mockedSubmitSetup = vi.mocked(submitSetup);
|
||||
|
||||
function renderPage() {
|
||||
@@ -38,8 +43,12 @@ describe("SetupPage", () => {
|
||||
});
|
||||
|
||||
it("shows a full-screen spinner while the setup status check is in flight", () => {
|
||||
// getSetupStatus never resolves — spinner should be visible immediately.
|
||||
mockedGetSetupStatus.mockReturnValue(new Promise(() => {}));
|
||||
mockedUseSharedSetupStatus.mockReturnValue({
|
||||
status: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
renderPage();
|
||||
expect(screen.getByRole("progressbar")).toBeInTheDocument();
|
||||
// Form should NOT be visible yet.
|
||||
@@ -49,8 +58,12 @@ describe("SetupPage", () => {
|
||||
});
|
||||
|
||||
it("renders the setup form once the status check resolves (not complete)", async () => {
|
||||
// Task 0.4: form must not flash before the check resolves.
|
||||
mockedGetSetupStatus.mockResolvedValue({ completed: false });
|
||||
mockedUseSharedSetupStatus.mockReturnValue({
|
||||
status: { completed: false },
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@@ -62,7 +75,12 @@ describe("SetupPage", () => {
|
||||
});
|
||||
|
||||
it("displays password complexity feedback while the user types", async () => {
|
||||
mockedGetSetupStatus.mockResolvedValue({ completed: false });
|
||||
mockedUseSharedSetupStatus.mockReturnValue({
|
||||
status: { completed: false },
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -82,7 +100,12 @@ describe("SetupPage", () => {
|
||||
});
|
||||
|
||||
it("does not submit the form when the password is too weak", async () => {
|
||||
mockedGetSetupStatus.mockResolvedValue({ completed: false });
|
||||
mockedUseSharedSetupStatus.mockReturnValue({
|
||||
status: { completed: false },
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
mockedSubmitSetup.mockResolvedValue({ message: "Setup completed successfully. Please log in." });
|
||||
renderPage();
|
||||
|
||||
@@ -101,7 +124,12 @@ describe("SetupPage", () => {
|
||||
});
|
||||
|
||||
it("redirects to /login when setup is already complete", async () => {
|
||||
mockedGetSetupStatus.mockResolvedValue({ completed: true });
|
||||
mockedUseSharedSetupStatus.mockReturnValue({
|
||||
status: { completed: true },
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("login-page")).toBeInTheDocument();
|
||||
@@ -109,7 +137,12 @@ describe("SetupPage", () => {
|
||||
});
|
||||
|
||||
it("renders the form and surfaces the error message when the status check fails", async () => {
|
||||
mockedGetSetupStatus.mockRejectedValue(new Error("Connection refused"));
|
||||
mockedUseSharedSetupStatus.mockReturnValue({
|
||||
status: null,
|
||||
loading: false,
|
||||
error: "Connection refused",
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
|
||||
Reference in New Issue
Block a user