From d34c2be19bb2fced811e1060e0ee11eb95357791 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 06:03:57 -0400 Subject: [PATCH 1/6] feat(services): add stable dashboard skeleton --- .../services/ServicesDashboardSkeleton.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/app/services/ServicesDashboardSkeleton.tsx 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 + +
+ ); +} From b1f17b9e1d934c6f50fb55594861c38e74c396a5 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 06:04:08 -0400 Subject: [PATCH 2/6] feat(services): contain dashboard section crashes --- src/app/services/ServicesSectionBoundary.tsx | 75 ++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/app/services/ServicesSectionBoundary.tsx diff --git a/src/app/services/ServicesSectionBoundary.tsx b/src/app/services/ServicesSectionBoundary.tsx new file mode 100644 index 0000000..1e8bf1d --- /dev/null +++ b/src/app/services/ServicesSectionBoundary.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { + Component, + type ErrorInfo, + type ReactNode, +} from "react"; + +type ServicesSectionBoundaryProps = { + sectionName: string; + 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) { + console.error("Services dashboard section failed", { + section: this.props.sectionName, + error, + componentStack: info.componentStack, + }); + } + + 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. +

+ +
+ ); + } +} From 46e3f27d89715f549bb0b5d1fc63041f943025ef Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 06:04:20 -0400 Subject: [PATCH 3/6] feat(services): match route loading to dashboard layout --- src/app/services/loading.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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 ( + + + + + ); } From f5668a936f63ae0cea1d8fc51d77ef8b02a25302 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 06:04:40 -0400 Subject: [PATCH 4/6] feat(services): isolate dashboard sections --- src/app/services/page.tsx | 136 ++++++++++++++++++++++---------------- 1 file changed, 80 insertions(+), 56 deletions(-) diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index 75dd6ed..e423f3f 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -7,7 +7,8 @@ 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 { ServicesSectionBoundary } from "./ServicesSectionBoundary"; import { truncateMiddle } from "@/lib/format"; import { useToast } from "@/components/ToastProvider"; import { useClipboard } from "@/lib/useClipboard"; @@ -177,6 +178,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 ( +
    + {sortedServices?.map((service) => ( +
  • + + + {truncateMiddle(service.serviceId)} + + + {service.priceStroops} stroops / request + + + +
  • + ))} +
+ ); +} + export default function ServicesPage() { const [services, setServices] = useState(null); const [error, setError] = useState(null); @@ -188,8 +247,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 +323,26 @@ 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 && ( + + )} + ); } From f6edb7b426b338bf91e60c34d28989a20cf3ad8e Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 06:05:01 -0400 Subject: [PATCH 5/6] test(services): cover scoped loading and recovery --- src/app/services/resilience.test.tsx | 134 +++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 src/app/services/resilience.test.tsx diff --git a/src/app/services/resilience.test.tsx b/src/app/services/resilience.test.tsx new file mode 100644 index 0000000..a89276a --- /dev/null +++ b/src/app/services/resilience.test.tsx @@ -0,0 +1,134 @@ +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 { 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(); + expect(screen.getByRole("alert")).toHaveTextContent( + "Service list unavailable." + ); + expect(consoleError).toHaveBeenCalled(); + + 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(); + }); +}); From ed32656b273004fe9c7c68cfee2031d3ef1edc52 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 06:38:13 -0400 Subject: [PATCH 6/6] fix(services): add stable section error codes --- src/app/services/ServicesSectionBoundary.tsx | 29 ++++++++++++++++-- src/app/services/page.tsx | 15 +++++++-- src/app/services/resilience.test.tsx | 32 ++++++++++++++++---- 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/app/services/ServicesSectionBoundary.tsx b/src/app/services/ServicesSectionBoundary.tsx index 1e8bf1d..8333d11 100644 --- a/src/app/services/ServicesSectionBoundary.tsx +++ b/src/app/services/ServicesSectionBoundary.tsx @@ -6,8 +6,24 @@ import { 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; }; @@ -33,11 +49,14 @@ export class ServicesSectionBoundary extends Component< } componentDidCatch(error: Error, info: ErrorInfo) { - console.error("Services dashboard section failed", { + const failure: ServicesSectionFailure = { + code: this.props.errorCode, section: this.props.sectionName, error, componentStack: info.componentStack, - }); + }; + + console.error("Services dashboard section failed", failure); } private retry = () => { @@ -62,6 +81,12 @@ export class ServicesSectionBoundary extends Component< This section hit an unexpected error. The rest of the dashboard is still available.

+

+ Reference: {this.props.errorCode} +