diff --git a/src/components/charts/__tests__/chart-boundary-settles-stats.test.tsx b/src/components/charts/__tests__/chart-boundary-settles-stats.test.tsx new file mode 100644 index 000000000..0b0f0cede --- /dev/null +++ b/src/components/charts/__tests__/chart-boundary-settles-stats.test.tsx @@ -0,0 +1,48 @@ +/** + * A chart whose chunk fails never reports its visible window, and the stat + * strip above it holds its cells for that report. The boundary's catch + * settles it with "no window" so the strip paints the full-range summary + * instead of staying a skeleton for good. + */ +import type { ComponentProps, ReactElement } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { ChartBoundary } from "@/components/charts/chart-error-state"; +import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; + +type Props = ComponentProps; + +describe("chart boundary settles the stat strip", () => { + it("reports an empty window when the chart fails to mount", () => { + const onVisibleStats = vi.fn(); + const element = HealthChartDynamic({ + types: ["WEIGHT"], + onVisibleStats, + } as unknown as Props) as ReactElement<{ + onError?: (error: Error) => void; + }>; + + expect(element.props.onError).toBeTypeOf("function"); + element.props.onError?.(new Error("chunk")); + expect(onVisibleStats).toHaveBeenCalledWith(null); + }); + + it("passes no handler for a chart nobody reads stats from", () => { + const element = HealthChartDynamic({ + types: ["WEIGHT"], + } as unknown as Props) as ReactElement<{ onError?: unknown }>; + expect(element.props.onError).toBeUndefined(); + }); + + it("the boundary calls its handler when it catches", () => { + const onError = vi.fn(); + const boundary = new ChartBoundary({ + fallback: null, + children: null, + onError, + }); + const error = new Error("chunk"); + boundary.componentDidCatch(error); + expect(onError).toHaveBeenCalledWith(error); + }); +}); diff --git a/src/components/charts/chart-error-state.tsx b/src/components/charts/chart-error-state.tsx index 7a7c8bea3..fc9665a6f 100644 --- a/src/components/charts/chart-error-state.tsx +++ b/src/components/charts/chart-error-state.tsx @@ -95,13 +95,16 @@ export function ChartErrorState({ interface BoundaryProps { fallback: ReactNode; children: ReactNode; + /** Called once the boundary has caught, after the fallback commits. */ + onError?: (error: Error) => void; } interface BoundaryState { failed: boolean; } -class Boundary extends Component { +/** Exported for its unit test; mount `` instead. */ +export class ChartBoundary extends Component { state: BoundaryState = { failed: false }; static getDerivedStateFromError(): BoundaryState { @@ -118,6 +121,7 @@ class Boundary extends Component { }; g.__healthlog_onError?.(error); } + this.props.onError?.(error); } render(): ReactNode { @@ -134,12 +138,20 @@ class Boundary extends Component { */ export function ChartErrorBoundary({ children, + onError, }: { children: ReactNode; + /** + * Lets the page settle anything that waited on the chart: a stat strip + * that holds its cells for the chart's window would otherwise wait for a + * report the failed chart can never send. + */ + onError?: (error: Error) => void; }): ReactElement { const { t } = useTranslations(); return ( - {children} - + ); } diff --git a/src/components/charts/health-chart-dynamic.tsx b/src/components/charts/health-chart-dynamic.tsx index dd30ee900..d77026815 100644 --- a/src/components/charts/health-chart-dynamic.tsx +++ b/src/components/charts/health-chart-dynamic.tsx @@ -66,8 +66,15 @@ export function HealthChartDynamic( : props.showDataTable ? HealthChartLazyTable : HealthChartLazy; + // A chart that never mounts never reports its window, and a stat strip + // holding its cells for that report would stay a skeleton for good. The + // failure settles it with "no window", so the strip falls back to the + // full-range summary it already has. + const { onVisibleStats } = props; return ( - + onVisibleStats(null) : undefined} + > );