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

+5 -1
View File
@@ -7490,7 +7490,11 @@
"countOther": "{count} Fehlschläge",
"lastFailed": "Letzter Fehlschlag {when}"
},
"deleteAllDetail": "Nutzerkonten bleiben bestehen."
"deleteAllDetail": "Nutzerkonten bleiben bestehen.",
"cardError": {
"title": "Dieser Teil der Seite konnte nicht angezeigt werden",
"description": "Der Rest der Seite funktioniert weiter; versuche es erneut oder lade neu, wenn es bleibt."
}
},
"achievements": {
"title": "Erfolge",
+5 -1
View File
@@ -7490,7 +7490,11 @@
"countOther": "{count} failures",
"lastFailed": "Last failure {when}"
},
"deleteAllDetail": "User accounts will be preserved."
"deleteAllDetail": "User accounts will be preserved.",
"cardError": {
"title": "This part of the page could not be shown",
"description": "The rest of the page still works; try again or reload if it keeps failing."
}
},
"achievements": {
"title": "Achievements",
+5 -1
View File
@@ -7490,7 +7490,11 @@
"countOther": "{count} fallos",
"lastFailed": "Último fallo {when}"
},
"deleteAllDetail": "Las cuentas de usuario se conservarán."
"deleteAllDetail": "Las cuentas de usuario se conservarán.",
"cardError": {
"title": "Esta parte de la página no se pudo mostrar",
"description": "El resto de la página sigue funcionando; inténtalo de nuevo o recarga si el fallo continúa."
}
},
"achievements": {
"title": "Logros",
+5 -1
View File
@@ -7490,7 +7490,11 @@
"countOther": "{count} échecs",
"lastFailed": "Dernier échec {when}"
},
"deleteAllDetail": "Les comptes utilisateurs sont conservés."
"deleteAllDetail": "Les comptes utilisateurs sont conservés.",
"cardError": {
"title": "Cette partie de la page n’a pas pu s’afficher",
"description": "Le reste de la page fonctionne ; réessayez ou rechargez si le problème persiste."
}
},
"achievements": {
"title": "Réussites",
+5 -1
View File
@@ -7490,7 +7490,11 @@
"countOther": "{count} errori",
"lastFailed": "Ultimo errore {when}"
},
"deleteAllDetail": "Gli account utente vengono conservati."
"deleteAllDetail": "Gli account utente vengono conservati.",
"cardError": {
"title": "Questa parte della pagina non è stata mostrata",
"description": "Il resto della pagina funziona; riprova o ricarica se il problema continua."
}
},
"achievements": {
"title": "Obiettivi",
+4
View File
@@ -7490,6 +7490,10 @@
"typeLocal": "로컬 모델 (개인)",
"typeGateway": "OpenAI 호환 게이트웨이 (개인)",
"spendToday": "오늘({date}): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다."
},
"cardError": {
"title": "페이지의 이 부분을 표시할 수 없습니다",
"description": "페이지의 나머지 부분은 계속 작동합니다. 다시 시도하거나 계속 실패하면 새로고침하세요."
}
},
"achievements": {
+5 -1
View File
@@ -7490,7 +7490,11 @@
"countOther": "{count} niepowodzeń",
"lastFailed": "Ostatnie niepowodzenie {when}"
},
"deleteAllDetail": "Konta użytkowników zostaną zachowane."
"deleteAllDetail": "Konta użytkowników zostaną zachowane.",
"cardError": {
"title": "Nie udało się wyświetlić tej części strony",
"description": "Reszta strony działa; spróbuj ponownie lub odśwież, jeśli błąd się powtarza."
}
},
"achievements": {
"title": "Osiągnięcia",
+11 -2
View File
@@ -12,10 +12,11 @@
* read `useAuth()` to gate rendering).
*/
import type { JSX } from "react";
import { Children, type JSX } from "react";
import { useAuth } from "@/hooks/use-auth";
import { AboutSection } from "@/components/settings/about-section";
import { AdminCardBoundary } from "@/components/admin/admin-card-boundary";
import { AiQualitySection } from "@/components/admin/ai-quality-section";
import { AiServerKeySection } from "@/components/admin/ai-server-key-section";
import { ProviderHealthSection } from "@/components/admin/provider-health-section";
@@ -189,5 +190,13 @@ export function AdminSectionRenderer({
* believes, so it is gone.
*/
function SectionFrame({ children }: { children: React.ReactNode }) {
return <div className="space-y-6">{children}</div>;
// Each card fails alone: one that throws while rendering paints an error
// card in its place instead of taking its neighbours down with it.
return (
<div className="space-y-6">
{Children.map(children, (card) => (
<AdminCardBoundary>{card}</AdminCardBoundary>
))}
</div>
);
}
@@ -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>;
}
}