diff --git a/CHANGELOG.md b/CHANGELOG.md index 165ac99e9..19791fbb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/package.json b/package.json index 090d49004..cfca3d647 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index c50ff48bf..4d042fcf2 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index dcc1c6454..6ea6cff27 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -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; diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index d5fdd8ed8..9b0e9c651 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -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( () => diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index 47c016fa6..0eab8096b 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -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 diff --git a/src/lib/charts/__tests__/data-ready-latch.test.ts b/src/lib/charts/__tests__/data-ready-latch.test.ts new file mode 100644 index 000000000..21e9a46c7 --- /dev/null +++ b/src/lib/charts/__tests__/data-ready-latch.test.ts @@ -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\]\)/); + }); + } +}); diff --git a/src/lib/charts/data-ready-latch.ts b/src/lib/charts/data-ready-latch.ts new file mode 100644 index 000000000..edda8d65c --- /dev/null +++ b/src/lib/charts/data-ready-latch.ts @@ -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; +}