mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
881a8d9061
commit
f57b6bf979
10 files changed
+209
-8
No files matched your search
+5
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -7490,6 +7490,10 @@
|
||||
"typeLocal": "로컬 모델 (개인)",
|
||||
"typeGateway": "OpenAI 호환 게이트웨이 (개인)",
|
||||
"spendToday": "오늘({date}): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다."
|
||||
},
|
||||
"cardError": {
|
||||
"title": "페이지의 이 부분을 표시할 수 없습니다",
|
||||
"description": "페이지의 나머지 부분은 계속 작동합니다. 다시 시도하거나 계속 실패하면 새로고침하세요."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
|
||||
+5
-1
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user