fix(admin): one card that fails to render no longer takes the page down

An admin page stacks independent cards, and a card that threw while
rendering replaced the whole page, so a field missing from the off-host
card's payload hid the snapshot table beside it. Every card in an admin
section now sits in its own boundary and fails as an error card with a
retry that remounts it; the rest of the page keeps working.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 03:19:39 +02:00
1 parent 881a8d9061
commit f57b6bf979
10 files changed
+209 -8

No files matched your search

@@ -0,0 +1,88 @@
/**
* One admin card that throws while rendering paints an error card in its own
* place, and the page around it keeps working.
*
* The component suite renders on the server, where React does not run error
* boundaries, so the boundary's three moves are exercised directly: a thrown
* render flips it to the fallback, the fallback is the translated error card
* with a retry, and the retry puts the card back under a new key so it
* remounts rather than re-rendering the tree that threw.
*/
import type { ReactElement, ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import {
AdminCardBoundary,
AdminCardError,
Boundary,
} from "../admin-card-boundary";
function markup(node: ReactNode): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
function boundaryWith(fallback: (retry: () => void) => ReactNode) {
const boundary = new Boundary({
renderFallback: fallback,
children: <p>card body</p>,
});
// Outside a mounted tree `setState` does nothing, so apply it in place.
boundary.setState = ((update: unknown) => {
const next =
typeof update === "function"
? (update as (s: typeof boundary.state) => object)(boundary.state)
: (update as object);
boundary.state = { ...boundary.state, ...next };
}) as typeof boundary.setState;
return boundary;
}
describe("AdminCardBoundary", () => {
it("renders the card untouched while nothing has thrown", () => {
const html = markup(
<AdminCardBoundary>
<p data-slot="healthy-card">card body</p>
</AdminCardBoundary>,
);
expect(html).toContain('data-slot="healthy-card"');
expect(html).not.toContain('data-slot="query-error-card"');
});
it("turns a thrown render into the fallback, not a page failure", () => {
expect(Boundary.getDerivedStateFromError()).toEqual({ failed: true });
const fallback = vi.fn(() => <p data-slot="fallback">fallback</p>);
const boundary = boundaryWith(fallback);
boundary.setState(Boundary.getDerivedStateFromError());
expect(markup(boundary.render())).toContain('data-slot="fallback"');
expect(fallback).toHaveBeenCalledWith(boundary.retry);
});
it("paints the translated error card with a keyboard-reachable retry", () => {
const html = markup(<AdminCardError onRetry={() => {}} />);
expect(html).toContain('data-slot="query-error-card"');
expect(html).toContain('role="alert"');
expect(html).toContain("This part of the page could not be shown");
expect(html).toContain("The rest of the page still works");
expect(html).toContain('data-slot="query-error-retry"');
});
it("retries by remounting the card under a new key", () => {
const boundary = boundaryWith(() => null);
const before = boundary.render() as ReactElement;
boundary.setState({ failed: true });
boundary.retry();
expect(boundary.state.failed).toBe(false);
const after = boundary.render() as ReactElement;
expect(after.key).not.toBe(before.key);
expect(markup(after)).toContain("card body");
});
});
@@ -0,0 +1,76 @@
"use client";
import { Component, Fragment, type ReactNode } from "react";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { useTranslations } from "@/lib/i18n/context";
/**
* One admin card's error boundary.
*
* An admin page stacks independent cards, each reading its own endpoint. A
* card that threw while rendering took the whole page with it: a field the
* off-host card read and a stale payload did not carry left the backups page
* without its snapshot table, the one card the operator had come for. Each
* card now fails alone, as a `QueryErrorCard` in its place, and the others
* keep working. "Try again" remounts the card, which refetches what it reads;
* the error still reaches the client error tracker.
*/
export function AdminCardBoundary({ children }: { children: ReactNode }) {
return (
<Boundary renderFallback={(retry) => <AdminCardError onRetry={retry} />}>
{children}
</Boundary>
);
}
/** What a failed card paints in its place. */
export function AdminCardError({ onRetry }: { onRetry: () => void }) {
const { t } = useTranslations();
return (
<QueryErrorCard
title={t("admin.cardError.title")}
description={t("admin.cardError.description")}
onRetry={onRetry}
/>
);
}
export interface BoundaryProps {
renderFallback: (retry: () => void) => ReactNode;
children: ReactNode;
}
interface BoundaryState {
failed: boolean;
attempt: number;
}
/** The boundary itself, exported for its unit test. */
export class Boundary extends Component<BoundaryProps, BoundaryState> {
state: BoundaryState = { failed: false, attempt: 0 };
static getDerivedStateFromError(): Pick<BoundaryState, "failed"> {
return { failed: true };
}
componentDidCatch(error: Error): void {
if (typeof window !== "undefined") {
const g = window as typeof window & {
__healthlog_onError?: (err: Error) => void;
};
g.__healthlog_onError?.(error);
}
}
retry = (): void => {
this.setState((s) => ({ failed: false, attempt: s.attempt + 1 }));
};
render(): ReactNode {
if (this.state.failed) return this.props.renderFallback(this.retry);
// A new key on retry remounts the card, so it starts from a clean state
// rather than re-rendering the tree that threw.
return <Fragment key={this.state.attempt}>{this.props.children}</Fragment>;
}
}