mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
1 parent
009d923c87
commit
2c112c3323
8 files changed
+210
-10
No files matched your search
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user