mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +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
@@ -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