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
+
+ {Array.from({ length: rowCount }, (_, index) => (
+ -
+
+
+
+ ))}
+
+
+ );
+}
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 (
+
+ {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 +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();
+ });
+});