From efbaf02335b38531c0874d8ddb74c6cac45cd4f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 21:30:59 +0200 Subject: [PATCH] fix(insights): settle the stat strip when its chart fails to load The strip holds min, max, median and mean until the chart reports its visible window. A chart chunk that failed was caught by the boundary and never reported, so the cells stayed skeletons although the summary was already there. The boundary now settles the strip with no window. --- .../chart-boundary-settles-stats.test.tsx | 48 +++++++++++++++++++ src/components/charts/chart-error-state.tsx | 18 +++++-- .../charts/health-chart-dynamic.tsx | 9 +++- 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 src/components/charts/__tests__/chart-boundary-settles-stats.test.tsx 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} + > );