diff --git a/src/app/services/ServicesDashboardSkeleton.tsx b/src/app/services/ServicesDashboardSkeleton.tsx new file mode 100644 index 0000000..b9f11c3 --- /dev/null +++ b/src/app/services/ServicesDashboardSkeleton.tsx @@ -0,0 +1,39 @@ +type ServicesDashboardSkeletonProps = { + rows?: number; +}; + +/** + * Mirrors the service-row footprint so loading-to-content swaps keep the + * dashboard geometry stable while the status region announces progress. + */ +export function ServicesDashboardSkeleton({ + rows = 5, +}: ServicesDashboardSkeletonProps) { + const rowCount = Math.max(1, Math.floor(rows)); + + return ( +
+ Loading services + +
+ ); +} diff --git a/src/app/services/ServicesSectionBoundary.tsx b/src/app/services/ServicesSectionBoundary.tsx new file mode 100644 index 0000000..8333d11 --- /dev/null +++ b/src/app/services/ServicesSectionBoundary.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { + Component, + type ErrorInfo, + type ReactNode, +} from "react"; + +export const SERVICES_SECTION_ERROR_CODES = { + serviceList: "SERVICES_LIST_RENDER_FAILED", + pagination: "SERVICES_PAGINATION_RENDER_FAILED", +} as const; + +export type ServicesSectionErrorCode = + (typeof SERVICES_SECTION_ERROR_CODES)[keyof typeof SERVICES_SECTION_ERROR_CODES]; + +export type ServicesSectionFailure = { + code: ServicesSectionErrorCode; + section: string; + error: Error; + componentStack: string | null; +}; + +type ServicesSectionBoundaryProps = { + sectionName: string; + errorCode: ServicesSectionErrorCode; + children: ReactNode; +}; + +type ServicesSectionBoundaryState = { + hasError: boolean; +}; + +/** + * Keeps one services-dashboard subtree from taking down its siblings. + * Render failures are logged for observability while the UI intentionally + * avoids exposing exception details to users. + */ +export class ServicesSectionBoundary extends Component< + ServicesSectionBoundaryProps, + ServicesSectionBoundaryState +> { + state: ServicesSectionBoundaryState = { + hasError: false, + }; + + static getDerivedStateFromError(): ServicesSectionBoundaryState { + return { hasError: true }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + const failure: ServicesSectionFailure = { + code: this.props.errorCode, + section: this.props.sectionName, + error, + componentStack: info.componentStack, + }; + + console.error("Services dashboard section failed", failure); + } + + private retry = () => { + this.setState({ hasError: false }); + }; + + render() { + if (!this.state.hasError) { + return this.props.children; + } + + return ( +
+

+ {this.props.sectionName} unavailable. +

+

+ This section hit an unexpected error. The rest of the dashboard is + still available. +

+

+ Reference: {this.props.errorCode} +

+ +
+ ); + } +} diff --git a/src/app/services/loading.tsx b/src/app/services/loading.tsx index 682f836..5c1c73c 100644 --- a/src/app/services/loading.tsx +++ b/src/app/services/loading.tsx @@ -1,5 +1,17 @@ -import { PageSkeleton } from "@/components/PageSkeleton"; +import { PageShell } from "@/components/PageShell"; +import { ServicesDashboardSkeleton } from "./ServicesDashboardSkeleton"; export default function Loading() { - return ; + return ( + + + + + ); } diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index 75dd6ed..ae02da6 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -7,7 +7,11 @@ import { ErrorMessage } from "@/components/ErrorMessage"; import { EmptyState } from "@/components/EmptyState"; import { PageShell } from "@/components/PageShell"; import { Pagination } from "@/components/Pagination"; -import { Spinner } from "@/components/Spinner"; +import { ServicesDashboardSkeleton } from "./ServicesDashboardSkeleton"; +import { + SERVICES_SECTION_ERROR_CODES, + ServicesSectionBoundary, +} from "./ServicesSectionBoundary"; import { truncateMiddle } from "@/lib/format"; import { useToast } from "@/components/ToastProvider"; import { useClipboard } from "@/lib/useClipboard"; @@ -177,6 +181,64 @@ export function ServiceCopyButton({ serviceId }: { serviceId: string }) { ); } + +function ServicesResults({ + services, + sortKey, + sortDir, +}: { + services: Service[]; + sortKey: SortKey; + sortDir: SortDir; +}) { + const sortedServices = useSorted(services, sortKey, sortDir); + + if (services.length === 0) { + return ( + + New service + + } + /> + ); + } + + return ( + + ); +} + export default function ServicesPage() { const [services, setServices] = useState(null); const [error, setError] = useState(null); @@ -188,8 +250,6 @@ export default function ServicesPage() { getInitialSort ); - const sortedServices = useSorted(services, sortKey, sortDir); - const handleSort = (key: SortKey) => { const next: { key: SortKey; dir: SortDir } = key === sortKey @@ -266,59 +326,32 @@ export default function ServicesPage() { - {loading && ( -
- -
- )} - {!loading && services && services.length === 0 && ( - - New service - - } - /> - )} - {!loading && services && services.length > 0 && ( -
    - {sortedServices?.map((s) => ( -
  • - - - {truncateMiddle(s.serviceId)} - - - {s.priceStroops} stroops / request - - - -
  • - ))} -
- )} - {!loading && !error && ( - - )} + + {loading ? ( + + ) : services ? ( + + ) : null} + + + {!loading && !error && ( + + )} + ); } diff --git a/src/app/services/resilience.test.tsx b/src/app/services/resilience.test.tsx new file mode 100644 index 0000000..62fa27a --- /dev/null +++ b/src/app/services/resilience.test.tsx @@ -0,0 +1,154 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { apiGet } from "../../lib/apiClient"; +import { ToastProvider } from "../../components/ToastProvider"; +import ServicesPage from "./page"; +import { ServicesDashboardSkeleton } from "./ServicesDashboardSkeleton"; +import { + SERVICES_SECTION_ERROR_CODES, + ServicesSectionBoundary, +} from "./ServicesSectionBoundary"; + +jest.mock("../../lib/apiClient", () => ({ + apiGet: jest.fn(), +})); + +const apiGetMock = apiGet as jest.MockedFunction; + +function renderServicesPage() { + return render( + + + + ); +} + +describe("services dashboard resilience", () => { + beforeEach(() => { + apiGetMock.mockReset(); + window.history.replaceState(null, "", "/services"); + }); + + it("announces loading and swaps equal-height skeleton rows for content", async () => { + let resolveRequest: + | ((value: { + services: Array<{ serviceId: string; priceStroops: number }>; + page: number; + pageCount: number; + }) => void) + | undefined; + + apiGetMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveRequest = resolve; + }) as never + ); + + renderServicesPage(); + + const status = screen.getByRole("status"); + expect(status).toHaveAttribute("aria-live", "polite"); + expect(status).toHaveAttribute("aria-busy", "true"); + + const skeletonRows = screen.getAllByTestId("service-row-skeleton"); + expect(skeletonRows).toHaveLength(5); + skeletonRows.forEach((row) => { + expect(row.className).toContain("min-h-12"); + }); + + await act(async () => { + resolveRequest?.({ + services: [{ serviceId: "svc-live", priceStroops: 42 }], + page: 1, + pageCount: 1, + }); + }); + + const serviceLink = await screen.findByRole("link", { + name: /svc-live/i, + }); + expect(serviceLink.closest("li")?.className).toContain("min-h-12"); + expect(screen.queryByTestId("service-row-skeleton")).not.toBeInTheDocument(); + }); + + it("renders the requested number of skeleton rows", () => { + render(); + + expect(screen.getAllByTestId("service-row-skeleton")).toHaveLength(2); + }); + + it("contains a render crash to one section and logs it", () => { + const consoleError = jest + .spyOn(console, "error") + .mockImplementation(() => undefined); + + function BrokenSection(): never { + throw new Error("render exploded"); + } + + render( + <> +
Healthy dashboard sibling
+ + + + + ); + + expect(screen.getByText("Healthy dashboard sibling")).toBeInTheDocument(); + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("Service list unavailable."); + expect(alert).toHaveTextContent( + `Reference: ${SERVICES_SECTION_ERROR_CODES.serviceList}` + ); + expect(alert).not.toHaveTextContent("render exploded"); + expect(consoleError).toHaveBeenCalledWith( + "Services dashboard section failed", + expect.objectContaining({ + code: SERVICES_SECTION_ERROR_CODES.serviceList, + section: "Service list", + error: expect.any(Error), + componentStack: expect.any(String), + }) + ); + + consoleError.mockRestore(); + }); + + it("re-renders only the failed section when retry is requested", () => { + const consoleError = jest + .spyOn(console, "error") + .mockImplementation(() => undefined); + let shouldThrow = true; + + function FlakySection() { + if (shouldThrow) { + throw new Error("temporary render failure"); + } + return
Recovered service list
; + } + + render( + + + + ); + + expect(screen.getByRole("alert")).toBeInTheDocument(); + + shouldThrow = false; + fireEvent.click( + screen.getByRole("button", { name: "Retry section" }) + ); + + expect(screen.getByText("Recovered service list")).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + + consoleError.mockRestore(); + }); +});