fix(dashboard): stop the chart-row render loop on tab resume

A user reported the dashboard crashing on returning to a backgrounded
tab. The on-focus snapshot refetch re-rendered the page, and each chart
received a fresh inline `() => markChartReady(id)` handler every render;
the chart's data-ready notify effect, keyed on that ever-changing prop,
re-fired on every commit. That per-commit passive effect kept the chart
row's anchored popovers re-committing until the browser tripped its
render-depth guard and the page fell back to its error card.

Latch the notify behind a ref so it fires once, when the chart's initial
query settles, and depend on the load flag alone. The handler is read
through a ref so a late-arriving callback still fires without re-arming
the effect. Extract the pure fire-once decision into a small helper with
direct unit coverage (no DOM runner, per project convention).
This commit is contained in:
Marc-André Bombeck committed 2026-06-23 21:45:42 +02:00
1 parent 009d923c87
commit 2c112c3323
8 files changed
+210 -10

No files matched your search

+29 -2
View File
@@ -23,6 +23,7 @@ import {
useMemo,
useEffect,
useCallback,
useRef,
type ComponentType,
} from "react";
import { Skeleton } from "@/components/ui/skeleton";
@@ -60,6 +61,7 @@ import {
computeWindowStats,
type MetricWindowStats,
} from "@/lib/charts/window-stats";
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
const TIME_RANGES_KEYS = [
{
@@ -1015,9 +1017,34 @@ export function HealthChart({
// range-tab change creates a new cache entry but the gate has long
// latched by then), so this fires exactly when the first paintable
// state — chart, empty-window card, or error fallback — is available.
//
// v1.20.1 — fire exactly once, gated on a ref rather than on the
// `onDataReady` prop identity. The dashboard passes an inline
// `() => markChartReady(id)` closure, so `onDataReady` is a NEW
// reference on every parent render; keying the effect on it re-ran the
// notify on every commit (e.g. a snapshot refetch on tab-resume), and
// that per-commit passive effect kept the chart-row's Radix-Popper
// anchors re-committing until React tripped its update-depth guard
// (#185). The ready signal is monotonic — once a chart has settled it
// stays settled — so latch it behind a ref and depend on `isLoading`
// alone. `onDataReady` is read through a ref so a late-arriving handler
// still fires without re-arming the effect.
const onDataReadyRef = useRef(onDataReady);
useEffect(() => {
if (!isLoading) onDataReady?.();
}, [isLoading, onDataReady]);
onDataReadyRef.current = onDataReady;
}, [onDataReady]);
const dataReadyFiredRef = useRef(false);
useEffect(() => {
if (
!shouldFireDataReady({
isLoading,
alreadyFired: dataReadyFiredRef.current,
})
)
return;
dataReadyFiredRef.current = true;
onDataReadyRef.current?.();
}, [isLoading]);
const chartData = useMemo(() => {
if (!data?.length) return data;
@@ -33,7 +33,7 @@
* the wrapper itself stays intact.
*/
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, useRef } from "react";
import { queryKeys } from "@/lib/query-keys";
import {
ResponsiveContainer,
@@ -63,6 +63,7 @@ import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
import { useViewportWidth } from "@/hooks/use-viewport-width";
import { chooseTickInterval } from "@/lib/charts/x-axis-density";
import { apiGet } from "@/lib/api/api-fetch";
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
interface DailyCompliancePoint {
/** Berlin calendar day, "YYYY-MM-DD". */
@@ -255,9 +256,29 @@ export function MedicationComplianceChart({
// v1.16.0 — report the settled query to the dashboard's shared
// reveal gate (see `onDataReady` prop doc).
//
// v1.20.1 — fire once via a ref instead of keying on the unstable
// `onDataReady` prop. See the matching note in `health-chart.tsx`: the
// dashboard hands every chart a fresh `() => markChartReady(id)` closure
// each render, so depending on it re-ran this notify on every commit and
// the per-commit passive effect kept the Radix-Popper tile anchors
// re-committing until React's update-depth guard tripped (#185).
const onDataReadyRef = useRef(onDataReady);
useEffect(() => {
if (!isLoading) onDataReady?.();
}, [isLoading, onDataReady]);
onDataReadyRef.current = onDataReady;
}, [onDataReady]);
const dataReadyFiredRef = useRef(false);
useEffect(() => {
if (
!shouldFireDataReady({
isLoading,
alreadyFired: dataReadyFiredRef.current,
})
)
return;
dataReadyFiredRef.current = true;
onDataReadyRef.current?.();
}, [isLoading]);
const chartData = useMemo(
() =>
+24 -3
View File
@@ -14,7 +14,7 @@ import {
ReferenceArea,
ReferenceLine,
} from "recharts";
import { useEffect, useState, useMemo } from "react";
import { useEffect, useState, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
@@ -40,6 +40,7 @@ import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
import { useViewportWidth } from "@/hooks/use-viewport-width";
import { computeTickPositions } from "@/lib/charts/x-axis-density";
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
import { moodLabelKeyForScore } from "@/lib/mood/labels";
import { apiGet } from "@/lib/api/api-fetch";
@@ -361,9 +362,29 @@ export function MoodChart({
// gate (see `onDataReady` prop doc). Must run BEFORE the
// empty-entries early return below so a no-data mount still unblocks
// the row.
//
// v1.20.1 — fire once via a ref instead of keying on the unstable
// `onDataReady` prop. See the matching note in `health-chart.tsx`: the
// dashboard hands every chart a fresh `() => markChartReady(id)` closure
// each render, so depending on it re-ran this notify on every commit and
// the per-commit passive effect kept the Radix-Popper tile anchors
// re-committing until React's update-depth guard tripped (#185).
const onDataReadyRef = useRef(onDataReady);
useEffect(() => {
if (!isLoading) onDataReady?.();
}, [isLoading, onDataReady]);
onDataReadyRef.current = onDataReady;
}, [onDataReady]);
const dataReadyFiredRef = useRef(false);
useEffect(() => {
if (
!shouldFireDataReady({
isLoading,
alreadyFired: dataReadyFiredRef.current,
})
)
return;
dataReadyFiredRef.current = true;
onDataReadyRef.current?.();
}, [isLoading]);
// v1.4.43 W2-CHART-GATE — raw mood-entry count across every day in
// the window. `entries[].samples` carries the per-day raw count