mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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
@@ -2,6 +2,14 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.20.1] — 2026-06-23 — Dashboard render-loop fix
|
||||
|
||||
A patch release. One fix; no schema changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A user reported the dashboard crashing on returning to a backgrounded tab. The on-focus snapshot refetch re-rendered the page, and each chart's "data ready" notify — keyed on a handler that the page recreated every render — re-fired on every commit, driving the chart row into an unbounded update loop until the browser tripped its render-depth guard and the page fell back to its error card. The notify now fires once, when a chart's initial query settles, regardless of how often the page re-renders.
|
||||
|
||||
## [1.20.0] — 2026-06-22 — Coach on-demand retrieval, deeper recovery insight, Fitbit, and a leaner data tier
|
||||
|
||||
A feature release. The Coach fetches what it needs on demand instead of carrying a full snapshot; the recovery-gap learns from the symptom journal and sleep; Fitbit connects over its own Web API; the trend tier serves slope and variability without a live scan; and several built surfaces become discoverable. Two additive migrations (`0190`, `0191`); no breaking changes.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.20.0",
|
||||
"version": "1.20.1",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.20.0";
|
||||
/* @sw-version-fallback */ "v1.20.1";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* v1.20.1 — regression cover for the dashboard chart-row render loop.
|
||||
*
|
||||
* A user reported the dashboard crashing (blank error card) on returning
|
||||
* to a backgrounded tab. The snapshot query refetches on window focus,
|
||||
* re-rendering the page; every chart was handed an inline
|
||||
* `() => markChartReady(id)` closure, so the chart's `onDataReady` notify
|
||||
* effect — keyed on that ever-changing prop — re-fired on every commit.
|
||||
* That per-commit passive effect kept the tile strip's Radix-Popper
|
||||
* anchors re-committing until React tripped its update-depth guard
|
||||
* (minified React error #185).
|
||||
*
|
||||
* The fix latches the notify behind a ref so it fires exactly once on the
|
||||
* first non-loading commit and depends on `isLoading` alone. This pins the
|
||||
* pure decision and the charts' wiring so the latch cannot silently revert
|
||||
* to the per-render notify.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
|
||||
|
||||
describe("shouldFireDataReady", () => {
|
||||
it("holds while the chart's initial query is still loading", () => {
|
||||
expect(shouldFireDataReady({ isLoading: true, alreadyFired: false })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("fires once on the first non-loading commit", () => {
|
||||
expect(shouldFireDataReady({ isLoading: false, alreadyFired: false })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("never re-fires after it has fired once, even though the chart keeps re-rendering", () => {
|
||||
// Models the tab-resume path: the parent re-renders repeatedly (a new
|
||||
// `onDataReady` closure each time) while `isLoading` stays false. The
|
||||
// latch must decline every subsequent commit so the notify cannot
|
||||
// drive an unbounded render loop.
|
||||
let fired = false;
|
||||
let fireCount = 0;
|
||||
for (let commit = 0; commit < 60; commit += 1) {
|
||||
if (shouldFireDataReady({ isLoading: false, alreadyFired: fired })) {
|
||||
fired = true;
|
||||
fireCount += 1;
|
||||
}
|
||||
}
|
||||
expect(fireCount).toBe(1);
|
||||
});
|
||||
|
||||
it("a chart that never loaded data still fires once the moment it settles", () => {
|
||||
// Loading first, then settled — fires on the settle commit, then holds.
|
||||
expect(shouldFireDataReady({ isLoading: true, alreadyFired: false })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(shouldFireDataReady({ isLoading: false, alreadyFired: false })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(shouldFireDataReady({ isLoading: false, alreadyFired: true })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chart notify wiring", () => {
|
||||
const charts = [
|
||||
"src/components/charts/health-chart.tsx",
|
||||
"src/components/charts/mood-chart.tsx",
|
||||
"src/components/charts/medication-compliance-chart.tsx",
|
||||
];
|
||||
|
||||
for (const rel of charts) {
|
||||
const src = readFileSync(join(process.cwd(), rel), "utf8");
|
||||
|
||||
it(`${rel} latches the data-ready notify once, not per render`, () => {
|
||||
// Routes through the pure latch.
|
||||
expect(src).toContain("shouldFireDataReady(");
|
||||
// Reads the handler through a ref so a changing prop identity never
|
||||
// re-arms the effect.
|
||||
expect(src).toContain("onDataReadyRef.current");
|
||||
// The notify effect depends on `isLoading` ALONE — never on the
|
||||
// unstable `onDataReady` prop (the loop trigger).
|
||||
expect(src).not.toMatch(/\}, \[isLoading, onDataReady\]\)/);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* v1.20.1 — pure decision behind the charts' one-shot `onDataReady`
|
||||
* notify.
|
||||
*
|
||||
* The dashboard's shared reveal gate (`useDashboardChartReveal`) listens
|
||||
* for each chart reporting its data query settled. Every chart receives
|
||||
* an inline `() => markChartReady(id)` closure from the dashboard, so the
|
||||
* `onDataReady` prop is a NEW reference on every parent render. Keying the
|
||||
* notify effect on that prop re-ran it on every commit — and a refetch on
|
||||
* tab-resume (`refetchOnWindowFocus`) re-rendered the page repeatedly,
|
||||
* which kept that per-commit passive effect firing and the chart row's
|
||||
* Radix-Popper anchors re-committing until React tripped its update-depth
|
||||
* guard (the minified React error #185 a user reported on returning to a
|
||||
* backgrounded tab).
|
||||
*
|
||||
* The ready signal is monotonic: once a chart's initial query has settled
|
||||
* it never un-settles (a later range-tab change opens a fresh cache entry,
|
||||
* but the gate has long latched by then). So the notify must fire exactly
|
||||
* once — on the first non-loading commit — and never again, regardless of
|
||||
* the `onDataReady` prop identity.
|
||||
*
|
||||
* This helper is the pure core of that latch, exported so the gate has
|
||||
* direct unit coverage without mounting React (project convention:
|
||||
* SSR-only tests, no DOM runner). The chart wires it to a `useRef` flag.
|
||||
*/
|
||||
export function shouldFireDataReady(input: {
|
||||
/** The chart's query is still on its initial load. */
|
||||
isLoading: boolean;
|
||||
/** Whether the notify has already fired this mount. */
|
||||
alreadyFired: boolean;
|
||||
}): boolean {
|
||||
if (input.isLoading) return false;
|
||||
if (input.alreadyFired) return false;
|
||||
return true;
|
||||
}
|
||||
Reference in new issue
Block a user