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

@@ -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();
});
});