From f57b6bf9794ba08159c31c76d731d5940f8bfe66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 03:19:39 +0200 Subject: [PATCH] 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. --- messages/de.json | 6 +- messages/en.json | 6 +- messages/es.json | 6 +- messages/fr.json | 6 +- messages/it.json | 6 +- messages/ko.json | 4 + messages/pl.json | 6 +- src/app/admin/[section]/renderer.tsx | 13 ++- .../__tests__/admin-card-boundary.test.tsx | 88 +++++++++++++++++++ src/components/admin/admin-card-boundary.tsx | 76 ++++++++++++++++ 10 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 src/components/admin/__tests__/admin-card-boundary.test.tsx create mode 100644 src/components/admin/admin-card-boundary.tsx diff --git a/messages/de.json b/messages/de.json index fad8a7342..5fea285f0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index f03291ef3..ff67a71e9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 2cf130ebd..241ebd154 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index dafcf4538..64cbe69f1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 8dbe44376..404c0ba2d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index a0f83be9a..8ce36f817 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7490,6 +7490,10 @@ "typeLocal": "로컬 모델 (개인)", "typeGateway": "OpenAI 호환 게이트웨이 (개인)", "spendToday": "오늘({date}): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다." + }, + "cardError": { + "title": "페이지의 이 부분을 표시할 수 없습니다", + "description": "페이지의 나머지 부분은 계속 작동합니다. 다시 시도하거나 계속 실패하면 새로고침하세요." } }, "achievements": { diff --git a/messages/pl.json b/messages/pl.json index dedf56606..fccc01aca 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index 24ac6042a..cfb9fdc8e 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -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
{children}
; + // 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 ( +
+ {Children.map(children, (card) => ( + {card} + ))} +
+ ); } diff --git a/src/components/admin/__tests__/admin-card-boundary.test.tsx b/src/components/admin/__tests__/admin-card-boundary.test.tsx new file mode 100644 index 000000000..275b0d52b --- /dev/null +++ b/src/components/admin/__tests__/admin-card-boundary.test.tsx @@ -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( + {node}, + ); +} + +function boundaryWith(fallback: (retry: () => void) => ReactNode) { + const boundary = new Boundary({ + renderFallback: fallback, + children:

card body

, + }); + // 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( + +

card body

+
, + ); + 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(() =>

fallback

); + 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( {}} />); + + 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"); + }); +}); diff --git a/src/components/admin/admin-card-boundary.tsx b/src/components/admin/admin-card-boundary.tsx new file mode 100644 index 000000000..6db7cf035 --- /dev/null +++ b/src/components/admin/admin-card-boundary.tsx @@ -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 ( + }> + {children} + + ); +} + +/** What a failed card paints in its place. */ +export function AdminCardError({ onRetry }: { onRetry: () => void }) { + const { t } = useTranslations(); + return ( + + ); +} + +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 { + state: BoundaryState = { failed: false, attempt: 0 }; + + static getDerivedStateFromError(): Pick { + 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 {this.props.children}; + } +}