From ae57025728fd09c9ee43e90e89cb5722053c07e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 11 Jul 2026 12:04:53 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.28.29=20=E2=80=94=20dashbo?= =?UTF-8?q?ard=20edits=20show=20up=20on=20the=20way=20back;=20one=20scroll?= =?UTF-8?q?bar=20again?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third and final layer of the stale-tile bug: saving invalidated the dashboard snapshot with the default refetchType 'active', but the dashboard page is unmounted while the user sits in settings — the cell only went stale, and the snapshot query's deliberate refetchOnMount: false then suppressed the re-read on same-tab navigation back. The save, reset and ring mutations now invalidate with refetchType 'all', refreshing the cell mounted or not (proven live in one SPA session against the real query client). The sortable settings editors (dashboard layout, medication order, modules, mood tags x2) painted a second page scrollbar: the sr-only drag hint below each list is position: absolute, no ancestor was positioned, so it resolved against the initial containing block, escaped the shell's overflow clipping and lengthened the DOCUMENT by its static offset. The list wrappers are now relative; the e2e overscroll guard asserts the one-scroll-floor on these routes and was negative-tested against the revert. --- CHANGELOG.md | 14 ++ docs/api/openapi.yaml | 2 +- e2e/settings-overscroll.spec.ts | 27 +++ package.json | 2 +- public/sw.js | 2 +- .../medications/medication-order-editor.tsx | 6 +- .../module-list/module-order-editor.tsx | 6 +- .../mood/manage/tag-groups-card.tsx | 6 +- .../mood/manage/tag-manager-card.tsx | 6 +- ...board-layout-snapshot-invalidation.test.ts | 178 ++++++++++++++++++ .../settings/dashboard-layout-section.tsx | 27 ++- 11 files changed, 268 insertions(+), 8 deletions(-) create mode 100644 src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c41aa958..cbb1b6c12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## [Unreleased] +## [1.28.29] — 2026-07-11 — Dashboard edits show up on the way back; one scrollbar again + +Changing the dashboard tile selection now shows up immediately when you +navigate back — the third and final layer of this bug. Saving marked the +dashboard's cached data stale but, with the dashboard page unmounted, nothing +re-read it until the next poll or a window-focus flick. The save now refreshes +that cache directly, mounted or not. + +The settings pages with sortable lists (dashboard layout, medication order, +modules, mood tags) showed a second page scrollbar: an invisible +screen-reader hint below each list escaped its container and silently +lengthened the document. All five editors are fixed and the overscroll guard +now covers these routes. + ## [1.28.28] — 2026-07-11 — OpenAI-compatible gateways work; a re-keyed night can no longer vanish The user-level "Local (OpenAI-compatible)" provider now speaks the standard diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 03c3eebea..66b284554 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.28 + version: 1.28.29 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/e2e/settings-overscroll.spec.ts b/e2e/settings-overscroll.spec.ts index 1b11d2cd6..544ecfb7b 100644 --- a/e2e/settings-overscroll.spec.ts +++ b/e2e/settings-overscroll.spec.ts @@ -27,7 +27,13 @@ const ROUTES = [ // Settings sub-shell — short hub, long sortable-list subpages, long form. "/settings/layout", "/settings/layout/dashboard", + // The other sortable-list subpages + the Modules hub: each hosts an + // order editor with the sr-only drag-hint paragraph (see the + // one-scroll-floor assertion below). "/settings/layout/insights", + "/settings/layout/medications", + "/settings/layout/mood", + "/settings/modules", "/settings/account", "/settings/notifications", // Admin sub-shell — short overview + a longer list page. Guards that the @@ -116,6 +122,27 @@ test.describe("settings + admin vertical over-scroll guard", () => { expect(dims, "main-content / wrapper present").not.toBeNull(); if (!dims) return; + // One-scroll-floor (UI-STANDARDS §9): `
` is the ONLY vertical + // scroll surface — the document itself must never become scrollable + // next to it (a second painted scrollbar + a dead dark band under + // the shell). The historic offender class: an absolutely-positioned + // sr-only element (the dnd drag-hint paragraph) whose containing + // block resolves to the initial containing block because no ancestor + // between it and the root is positioned — its static position below + // a long list then extends the DOCUMENT's scrollable overflow while + // `
` clips everything in normal flow. + const doc = await page.evaluate(() => ({ + scrollHeight: document.documentElement.scrollHeight, + clientHeight: document.documentElement.clientHeight, + })); + expect( + doc.scrollHeight, + `document scrollHeight=${doc.scrollHeight} > viewport ` + + `${doc.clientHeight} — a second vertical scroll surface exists ` + + `beside
(likely an absolutely-positioned element escaping ` + + `to the initial containing block)`, + ).toBeLessThanOrEqual(doc.clientHeight + 1); + // A page shorter than the viewport cannot over-scroll at all. if (dims.scrollHeight <= dims.clientHeight) return; diff --git a/package.json b/package.json index 4c630a11f..3bf1b91f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.28", + "version": "1.28.29", "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 8af20350a..6021ae46b 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.28.28"; + /* @sw-version-fallback */ "v1.28.29"; 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/medications/medication-order-editor.tsx b/src/components/medications/medication-order-editor.tsx index 6b0b5d3b5..262c8cf1a 100644 --- a/src/components/medications/medication-order-editor.tsx +++ b/src/components/medications/medication-order-editor.tsx @@ -147,7 +147,11 @@ export function MedicationOrderEditor({ const dirty = draftActive !== null || draftInactive !== null; return ( -
+ // `relative` anchors the sr-only drag-hint paragraph: `sr-only` is + // absolutely positioned, and without a positioned ancestor its 1px box + // lands below the list in DOCUMENT coordinates and makes the document + // itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor). +
{medications.length === 0 && (

{t("medications.emptyTitle")} diff --git a/src/components/module-list/module-order-editor.tsx b/src/components/module-list/module-order-editor.tsx index ce12ae97d..a0490f237 100644 --- a/src/components/module-list/module-order-editor.tsx +++ b/src/components/module-list/module-order-editor.tsx @@ -104,7 +104,11 @@ export function ModuleOrderEditor({ items, onChange }: ModuleOrderEditorProps) { } return ( -

+ // `relative` anchors the sr-only drag-hint paragraph: `sr-only` is + // absolutely positioned, and without a positioned ancestor its 1px box + // lands below the list in DOCUMENT coordinates and makes the document + // itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor). +
c.custom).length; return ( -
+ // `relative` anchors the sr-only drag-hint paragraph: `sr-only` is + // absolutely positioned, and without a positioned ancestor its 1px box + // lands below the list in DOCUMENT coordinates and makes the document + // itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor). +

{t("mood.manage.groupsDescription")} diff --git a/src/components/mood/manage/tag-manager-card.tsx b/src/components/mood/manage/tag-manager-card.tsx index 34f37066f..e51ba034d 100644 --- a/src/components/mood/manage/tag-manager-card.tsx +++ b/src/components/mood/manage/tag-manager-card.tsx @@ -250,7 +250,11 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) { } return ( -

+ // `relative` anchors the sr-only drag-hint paragraph: `sr-only` is + // absolutely positioned, and without a positioned ancestor its 1px box + // lands below the list in DOCUMENT coordinates and makes the document + // itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor). +

{t("mood.manage.tagsDescription")} diff --git a/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts b/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts new file mode 100644 index 000000000..8354baad1 --- /dev/null +++ b/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; + +import { queryKeys } from "@/lib/query-keys"; + +/** + * Saving the dashboard tile selection must surface on the dashboard on the + * very next SAME-TAB navigation — not "eventually" via the 120 s poll or a + * window-focus flick. + * + * The web dashboard (`src/app/page-client.tsx`) reads tile visibility/order + * from TWO cache cells: + * 1. the snapshot cell (`queryKeys.dashboardSnapshot(locale)` via + * `useDashboardSnapshot`) — `snapshotQuery.data.layout` is the layout + * source whenever snapshot mode is on (the default), and + * 2. the legacy `queryKeys.dashboardWidgets()` cell (snapshot mode off, plus + * the per-chart overlay-prefs hook). + * The settings save writes (2) directly via `setQueryData` with the server + * response, so its freshness is unconditional. (1) is only INVALIDATED — and + * that is where the same-tab staleness lived: + * + * While the user sits on /settings/layout/dashboard the dashboard page is + * unmounted, so the snapshot cell has no active observer. An + * `invalidateQueries` with the default `refetchType: "active"` merely marks + * the cell stale, and the snapshot query's deliberate `refetchOnMount: false` + * (`use-dashboard-snapshot.ts` — return-to-dashboard within a minute stays a + * free cache hit) then suppresses the mount-time refetch when the user + * navigates back. Result: "saved, but the dashboard shows the old tiles". + * The tests below pin that mechanism against the REAL TanStack QueryClient + * and the fix (`refetchType: "all"`), so neither half can silently regress. + */ + +/** Mirror of the layout-relevant options in `use-dashboard-snapshot.ts`. */ +function snapshotObserverOptions( + client: QueryClient, + queryFn: () => Promise, +) { + return new QueryObserver(client, { + queryKey: queryKeys.dashboardSnapshot("en"), + queryFn, + staleTime: 60_000, + refetchOnMount: false, + retry: false, + }); +} + +function makeClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +describe("dashboard snapshot invalidation — same-tab staleness mechanism", () => { + it("default refetchType leaves the unmounted snapshot cell stale across a remount (the defect)", async () => { + const client = makeClient(); + let fetchCount = 0; + const queryFn = async () => { + fetchCount += 1; + return { layout: `v${fetchCount}` }; + }; + + // Dashboard mounted — first fetch. Wait for the COMMITTED data, not just + // the queryFn call: an observer-less query that never committed a fetch + // reads as disabled and would be skipped by refetchQueries. + const observer = snapshotObserverOptions(client, queryFn); + const unsubscribe = observer.subscribe(() => {}); + await vi.waitFor(() => + expect(observer.getCurrentResult().data).toEqual({ layout: "v1" }), + ); + + // Navigate to Settings → the dashboard unmounts. + unsubscribe(); + + // The PRE-fix save-mutation call: zero-arg key (prefix-matches the + // locale-keyed cell), default refetchType ("active"). + await client.invalidateQueries({ + queryKey: queryKeys.dashboardSnapshot(), + }); + expect(fetchCount).toBe(1); // inactive cell: marked stale, NOT refetched + + // Navigate back to the dashboard — remount. `refetchOnMount: false` + // suppresses the stale-triggered refetch: the OLD layout renders. + const observer2 = snapshotObserverOptions(client, queryFn); + const unsubscribe2 = observer2.subscribe(() => {}); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(fetchCount).toBe(1); + expect(observer2.getCurrentResult().data).toEqual({ layout: "v1" }); + unsubscribe2(); + client.clear(); + }); + + it('refetchType: "all" refetches the unmounted cell so the remount reads fresh layout (the fix)', async () => { + const client = makeClient(); + let fetchCount = 0; + const queryFn = async () => { + fetchCount += 1; + return { layout: `v${fetchCount}` }; + }; + + const observer = snapshotObserverOptions(client, queryFn); + const unsubscribe = observer.subscribe(() => {}); + await vi.waitFor(() => + expect(observer.getCurrentResult().data).toEqual({ layout: "v1" }), + ); + unsubscribe(); + + // The save-mutation call as shipped: zero-arg key still prefix-matches + // the locale-keyed cell, and `refetchType: "all"` fires the request + // immediately even though the dashboard is unmounted. + await client.invalidateQueries({ + queryKey: queryKeys.dashboardSnapshot(), + refetchType: "all", + }); + expect(fetchCount).toBe(2); + + // Remount: fresh data is already in the cache — no refetch needed. + const observer2 = snapshotObserverOptions(client, queryFn); + const unsubscribe2 = observer2.subscribe(() => {}); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(observer2.getCurrentResult().data).toEqual({ layout: "v2" }); + unsubscribe2(); + client.clear(); + }); +}); + +describe("dashboard layout save — every layout source is covered (source pins)", () => { + const sectionSrc = readFileSync( + join(process.cwd(), "src/components/settings/dashboard-layout-section.tsx"), + "utf8", + ); + + it("all three snapshot invalidations (save / reset / rings) refetch inactive cells", () => { + const calls = + sectionSrc.match( + /invalidateQueries\(\{\s*queryKey: queryKeys\.dashboardSnapshot\(\),\s*refetchType: "all",\s*\}\)/g, + ) ?? []; + expect(calls).toHaveLength(3); + // No leftover default-refetchType snapshot invalidation. + const anySnapshotInvalidation = + sectionSrc.match( + /invalidateQueries\(\{\s*queryKey: queryKeys\.dashboardSnapshot\(\)/g, + ) ?? []; + expect(anySnapshotInvalidation).toHaveLength(3); + }); + + it("the legacy dashboardWidgets cell is written directly with the server response", () => { + expect(sectionSrc).toMatch( + /setQueryData\(queryKeys\.dashboardWidgets\(\), saved\)/, + ); + }); + + it("the dashboard reads layout from the snapshot (or the dashboardWidgets cell) only", () => { + const pageSrc = readFileSync( + join(process.cwd(), "src/app/page-client.tsx"), + "utf8", + ); + // Snapshot path: layout comes off the snapshot payload… + expect(pageSrc).toContain("snapshotQuery.data?.layout"); + // …legacy path: the dashboardWidgets factory key (no bare literal). + expect(pageSrc).toContain("queryKeys.dashboardWidgets()"); + // The analytics slices carry tile DATA (summaries), never layout — if + // someone threads layout through them, this pin forces a rethink of the + // save-path invalidation set. + expect(pageSrc).not.toMatch(/analytics[A-Za-z]*\??\.(data\.)?layout/); + }); + + it("refetchOnMount stays false on the snapshot hook (the reason refetchType-all is required)", () => { + const hookSrc = readFileSync( + join(process.cwd(), "src/lib/queries/use-dashboard-snapshot.ts"), + "utf8", + ); + expect(hookSrc).toContain("refetchOnMount: false"); + // v1.28.28 — focus refetch stays on (cross-tab freshness); do not revert. + expect(hookSrc).toContain("refetchOnWindowFocus: true"); + }); +}); diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index b4f165414..674af8433 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -241,8 +241,19 @@ export function DashboardLayoutSection({ id }: { id: string }) { // invalidation the snapshot keeps its cached layout and the dashboard // looks stale until a manual reload. Invalidate the snapshot so the // next visit (or open tab) re-reads the saved layout immediately. + // + // `refetchType: "all"` is load-bearing: while the user sits on this + // settings page the dashboard is UNMOUNTED, so its snapshot cell is + // inactive — the default `refetchType: "active"` only marks it stale, + // and the snapshot query's deliberate `refetchOnMount: false` + // (use-dashboard-snapshot.ts) then suppresses the mount-time refetch + // on a same-tab navigation back to `/`. The saved tile selection only + // surfaced on the 120 s poll or a window-focus flick ("saved but the + // dashboard shows old tiles"). Refetching ALL matching cells fires the + // request right here, so the dashboard mounts onto fresh data. queryClient.invalidateQueries({ queryKey: queryKeys.dashboardSnapshot(), + refetchType: "all", }); setDraft(null); toast.success(t("dashboard.layoutSaveSuccess")); @@ -258,8 +269,11 @@ export function DashboardLayoutSection({ id }: { id: string }) { queryClient.setQueryData(queryKeys.dashboardWidgets(), saved); // v1.18.10 — same snapshot invalidation as the save path so a reset // to defaults reflects on the Startseite without a manual reload. + // `refetchType: "all"` for the same reason as the save path: the + // unmounted snapshot cell must refetch NOW, not on its next poll. queryClient.invalidateQueries({ queryKey: queryKeys.dashboardSnapshot(), + refetchType: "all", }); setDraft(null); toast.success(t("dashboard.layoutResetSuccess")); @@ -363,8 +377,11 @@ export function DashboardLayoutSection({ id }: { id: string }) { }, onSuccess: (saved) => { queryClient.setQueryData(queryKeys.dashboardWidgets(), saved); + // `refetchType: "all"` for the same reason as the save path: the + // unmounted snapshot cell must refetch NOW, not on its next poll. queryClient.invalidateQueries({ queryKey: queryKeys.dashboardSnapshot(), + refetchType: "all", }); }, }); @@ -708,7 +725,15 @@ export function DashboardLayoutSection({ id }: { id: string }) { {t("common.loading")}

) : ( -
+ // `relative` anchors the sr-only drag-hint paragraph below: Tailwind's + // `sr-only` is `position: absolute`, and without a positioned ancestor + // its containing block is the initial containing block — the 1px box + // then sits at its static position (below this long list) in DOCUMENT + // coordinates and silently makes the document itself scrollable, a + // second vertical scrollbar next to the shell-owned `
` scroll + // (UI-STANDARDS §9 one-scroll-floor; guarded by + // e2e/settings-overscroll.spec.ts). +
{/* v1.4.15 Fix 5 — table-style header naming the two switches. The "tile" column controls the strip tile in the upper row; the "chart" column controls the line