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:
2026-04-26 13:28:09 +02:00
parent 5b24a9c142
commit 3095fa3313
8 changed files with 383 additions and 123 deletions

View File

@@ -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(