From 15c77597fcc9b257e9497cae9514a16490a6aa5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 13 Jun 2026 01:02:14 +0200 Subject: [PATCH] fix(e2e,insights): the coach specs follow the drawer, and the status cards stop tearing hydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coach launcher deliberately opens the side drawer in place now — the spec expected the old navigation and waited for a URL that never comes; it asserts the drawer (bottom-sheet on the phone viewport) and the nudge-dot lifecycle instead. The metric and slug status cards rendered locale-dependent content in the hydration pass, which React flagged as a mismatch (#418) on the dashboard and the mood page — they ride the established mounted gate now, pinning the server branch for the first paint. The midnight flake in the intake bucket tests is pinned to a fixed clock: the fixtures minted UTC days while the route buckets Berlin days, so runs between midnight and two failed. --- e2e/ai-provider-dropdown.spec.ts | 8 +++ e2e/v1427-coach-launch.spec.ts | 56 ++++++++++++++++--- .../dashboard-tile-strip-skeleton.test.ts | 5 +- .../intake/__tests__/route.test.ts | 19 +++++++ src/app/mood/page.tsx | 9 ++- src/app/page.tsx | 8 ++- .../healthkit-metric-page-status.test.tsx | 9 ++- .../insights/metric-status-card.tsx | 8 ++- .../insights/slug-insight-status-card.tsx | 9 ++- 9 files changed, 117 insertions(+), 14 deletions(-) diff --git a/e2e/ai-provider-dropdown.spec.ts b/e2e/ai-provider-dropdown.spec.ts index ff19ab433..757ca1d55 100644 --- a/e2e/ai-provider-dropdown.spec.ts +++ b/e2e/ai-provider-dropdown.spec.ts @@ -80,7 +80,15 @@ test.describe("Settings → AI provider dropdown UX (B2)", () => { }), ); + // Hydration gate — the provider query is fired by the same client + // boundary that renders the select, so its response proves React + // has hydrated the card and attached the select's change handler. + // `selectOption` on the SSR-painted element any earlier flips the + // native value without React noticing, and the config form below + // never switches (CI failure at slow hydration). + const cardHydrated = page.waitForResponse("**/api/user/ai-provider"); await page.goto("/settings/ai", { waitUntil: "domcontentloaded" }); + await cardHydrated; // 1. Dropdown surfaces on the page. const select = page.getByTestId("ai-active-provider-select"); diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts index cabecdadb..62177edf9 100644 --- a/e2e/v1427-coach-launch.spec.ts +++ b/e2e/v1427-coach-launch.spec.ts @@ -19,8 +19,9 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * bottom-sheet (`data-variant="bottom-sheet"`) on `=sm`. * - * 3. With an unread nudge the FAB carries the dot and clicking it - * navigates to the full-page chat at `/insights/coach`. + * 3. With an unread nudge the FAB carries the dot; clicking the FAB + * opens the Coach drawer in place (v1.16.11 — no navigation, the + * page underneath stays) and the dot clears. * * 4. The drawer's "Conversations" button no longer opens an in-panel * tray; it hands off to the full-page route `/insights/coach`. @@ -177,7 +178,15 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { "drawer bottom-sheet branch is mobile-only", ); + // Hydration gate — the status query is fired by the sub-page + // boundary itself, so its response proves React has hydrated the + // page and attached the launch icon's click handler. Clicking the + // SSR-painted icon any earlier is silently lost (CI flake). + const pageHydrated = page.waitForResponse( + /\/api\/insights\/blood-pressure-status/, + ); await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" }); + await pageHydrated; const icon = page.locator('[data-slot="coach-launch-icon"]').first(); await expect(icon).toBeVisible({ timeout: 10_000 }); @@ -196,7 +205,12 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { "drawer side-sheet branch is desktop-only", ); + // Hydration gate — see the bottom-sheet branch above. + const pageHydrated = page.waitForResponse( + /\/api\/insights\/blood-pressure-status/, + ); await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" }); + await pageHydrated; const icon = page.locator('[data-slot="coach-launch-icon"]').first(); await expect(icon).toBeVisible({ timeout: 10_000 }); @@ -207,9 +221,9 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { await expect(drawer).toHaveAttribute("data-variant", "side-sheet"); }); - test("an unread Coach nudge paints the FAB dot and clicking the FAB opens the full-page chat", async ({ + test("an unread Coach nudge paints the FAB dot and clicking the FAB opens the Coach drawer in place", async ({ page, - }) => { + }, testInfo) => { // The FAB contract is viewport-independent — the test runs on // both projects. Override the default nudge mock with an unread // one (later registration wins). @@ -224,7 +238,14 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { }), ); + // Hydration gate — the nudge-status query is fired by the FAB + // component itself, so its response proves the FAB's click handler + // is attached (the button paints from SSR HTML long before that). + const fabHydrated = page.waitForResponse( + /\/api\/insights\/coach\/nudge-status/, + ); await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" }); + await fabHydrated; const fab = page.locator('[data-slot="coach-fab"]'); await expect(fab).toBeVisible({ timeout: 10_000 }); @@ -236,10 +257,24 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { ).toBeVisible(); await fab.click(); - await page.waitForURL("**/insights/coach", { timeout: 10_000 }); - await expect( - page.locator('[data-slot="coach-page"]'), - ).toBeVisible({ timeout: 10_000 }); + // v1.16.11 — the FAB opens the side drawer in place instead of + // navigating to `/insights/coach`; the page underneath stays. + const drawer = page.locator('[data-slot="coach-drawer"]'); + await expect(drawer).toBeVisible({ timeout: 10_000 }); + await expect(drawer).toHaveAttribute( + "data-variant", + testInfo.project.name === "chromium-mobile" + ? "bottom-sheet" + : "side-sheet", + ); + expect(new URL(page.url()).pathname).not.toContain("/insights/coach"); + + // Opening the Coach counts as reading the nudge on this device — + // the dot clears and the unread flag drops off the FAB. + await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount( + 0, + ); + await expect(fab).not.toHaveAttribute("data-unread", "true"); }); test("the drawer's Conversations button hands off to the full-page chat on Desktop Chrome", async ({ @@ -253,7 +288,12 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { "exercises the desktop drawer layout", ); + // Hydration gate — see the bottom-sheet test above. + const pageHydrated = page.waitForResponse( + /\/api\/insights\/blood-pressure-status/, + ); await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" }); + await pageHydrated; const icon = page.locator('[data-slot="coach-launch-icon"]').first(); await expect(icon).toBeVisible({ timeout: 10_000 }); diff --git a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts index aed76afe3..ec774e363 100644 --- a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts +++ b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts @@ -177,8 +177,11 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => { const src = readFileSync(PAGE_PATH, "utf8"); // The placeholder count keys off the layout config minus the // already-mounted (layout-gated) cells, and only while loading. + // `mounted &&` pins the hydration render to the SSR output — the + // auth query can settle before the page boundary hydrates, and an + // extra BMI silhouette then mismatched the server HTML (React #418). expect(src).toMatch( - /const\s+chartRowPlaceholderCount\s*=\s*primaryLoading[\s\S]*?resolveChartRowPlaceholderCount\(layout,[\s\S]*?hasHeightCm:\s*Boolean\(user\?\.heightCm\)/, + /const\s+chartRowPlaceholderCount\s*=\s*primaryLoading[\s\S]*?resolveChartRowPlaceholderCount\(layout,[\s\S]*?hasHeightCm:\s*mounted\s*&&\s*Boolean\(user\?\.heightCm\)/, ); expect(src).toMatch(/-\s*charts\.length/); // The reserved cells render as aria-hidden ChartSkeletons in their diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index a09280791..d1c15d0f9 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -528,6 +528,25 @@ describe("v1.15.9 — schedule-anchored compliance buckets (BUG #1)", () => { // engine's expected-dose count per day (not the count of logged intake // rows). With partial adherence the per-day rate `taken / scheduled` // genuinely reflects taken-of-expected — it is NOT pinned at ~100%. + // + // The clock is pinned to a fixed midday instant: the fixtures mint one + // event per UTC day, but the route buckets by the user's local + // (Europe/Berlin) day — with a live clock, a run between 00:00 and + // ~01:59 local (UTC date still "yesterday") left the local TODAY + // bucket with `scheduled = 1, taken = 0` and failed the full-adherence + // assertion. At 12:00 UTC the UTC day and the Berlin day coincide, so + // the per-UTC-day fixture math is exact. Only `Date` is faked — the + // route awaits real promises. + beforeEach(() => { + vi.useFakeTimers({ + now: new Date("2026-06-10T12:00:00Z"), + toFake: ["Date"], + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); function activeDailyMed(createdAt: Date) { return { id: "m1", diff --git a/src/app/mood/page.tsx b/src/app/mood/page.tsx index 31865a6d3..f18efa3ce 100644 --- a/src/app/mood/page.tsx +++ b/src/app/mood/page.tsx @@ -4,6 +4,7 @@ import { useCallback, useState } from "react"; import Link from "next/link"; import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { MoodForm } from "@/components/mood/mood-form"; import { MoodList } from "@/components/mood/mood-list"; @@ -17,6 +18,7 @@ import { useTranslations } from "@/lib/i18n/context"; export default function MoodPage() { const { isAuthenticated, isLoading } = useAuth(); + const mounted = useMounted(); const router = useRouter(); const queryClient = useQueryClient(); const [dialogOpen, setDialogOpen] = useState(false); @@ -45,7 +47,12 @@ export default function MoodPage() { } }, [isLoading, isAuthenticated, router]); - if (isLoading) { + // `!mounted` keeps the hydration render identical to the SSR HTML: the + // auth query is fired by the early-hydrating shell and can settle before + // this page boundary hydrates, so `isLoading` alone flipped the branch + // and React logged hydration error #418 — same fix as the measurements + // page; see `useMounted`. + if (!mounted || isLoading) { return (
diff --git a/src/app/page.tsx b/src/app/page.tsx index 56d967b76..ba7c5126e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1429,11 +1429,17 @@ export default function DashboardPage() { // already mounted) so the page holds roughly its final // footprint from first paint and the snapshot landing swaps // content instead of growing the page. + // `mounted &&` pins the hydration render to the SSR output: the + // auth query is fired by the early-hydrating shell, so `user` + // can already carry a heightCm when this late-hydrating page + // boundary replays its first render — the extra BMI silhouette + // then mismatched the server HTML (React #418). The count may + // only personalise from the first client re-render. const chartRowPlaceholderCount = primaryLoading ? Math.max( 0, resolveChartRowPlaceholderCount(layout, { - hasHeightCm: Boolean(user?.heightCm), + hasHeightCm: mounted && Boolean(user?.heightCm), }) - charts.length, ) : 0; diff --git a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx index 0e5996a25..49ade7738 100644 --- a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx +++ b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx @@ -104,7 +104,14 @@ describe(" status mount", () => { />, ); - expect(html).toContain("Your HRV has trended upward"); + // The server pass renders the card's loading skeleton even when the + // mocked query reports settled data: `` pins SSR + + // the hydration render to the loading branch (`!mounted`) so a query + // that settles before a late-hydrating boundary replays its first + // render cannot mismatch the server HTML (React #418). The settled + // text swaps in on the first client re-render. + expect(html).toContain('data-testid="insight-status-card-loading"'); + expect(html).not.toContain("Your HRV has trended upward"); // The card fetch is enabled (statusMetric present + data present). expect(metricStatusMock).toHaveBeenCalledWith("HEART_RATE_VARIABILITY", true); }); diff --git a/src/components/insights/metric-status-card.tsx b/src/components/insights/metric-status-card.tsx index 9a2993cff..d91311599 100644 --- a/src/components/insights/metric-status-card.tsx +++ b/src/components/insights/metric-status-card.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import { useInsightMetricStatus } from "@/hooks/use-insight-status"; +import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; @@ -35,6 +36,7 @@ export function MetricStatusCard({ enabled, }: MetricStatusCardProps) { const { t } = useTranslations(); + const mounted = useMounted(); const { data: status, isLoading } = useInsightMetricStatus(metric, enabled); return ( @@ -44,7 +46,11 @@ export function MetricStatusCard({ text={status?.text ?? null} hasProvider={status?.hasProvider ?? false} updatedAt={status?.updatedAt ?? null} - loading={isLoading} + // Same hydration gate as ``: the hook is + // `enabled: isAuthenticated && enabled`, and both inputs can settle + // before a late-hydrating boundary replays its first render — the + // loading branch then disagrees with the server HTML (React #418). + loading={!mounted || isLoading} preparing={status?.preparing ?? false} /> ); diff --git a/src/components/insights/slug-insight-status-card.tsx b/src/components/insights/slug-insight-status-card.tsx index 39c00311e..a0d30de28 100644 --- a/src/components/insights/slug-insight-status-card.tsx +++ b/src/components/insights/slug-insight-status-card.tsx @@ -6,6 +6,7 @@ import { useInsightStatus, type InsightStatusMetric, } from "@/hooks/use-insight-status"; +import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; @@ -32,6 +33,7 @@ export function SlugInsightStatusCard({ icon, }: SlugInsightStatusCardProps) { const { t } = useTranslations(); + const mounted = useMounted(); const { data: status, isLoading } = useInsightStatus(slug); return ( @@ -41,7 +43,12 @@ export function SlugInsightStatusCard({ text={status?.text ?? null} hasProvider={status?.hasProvider ?? false} updatedAt={status?.updatedAt ?? null} - loading={isLoading} + // `!mounted ||` keeps the SSR pass and the hydration render on the + // same skeleton branch. The status query is `enabled: isAuthenticated`, + // and the auth query resolves from the early-hydrating shell — a + // late-hydrating page boundary then saw `isLoading` flip true while + // the server HTML carried the settled card (React #418). + loading={!mounted || isLoading} preparing={status?.preparing ?? false} /> );