diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index b809d4784..80374f8ed 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -6,6 +6,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; import { openMenu } from "./open-menu"; import { POPULATED_SUMMARIES } from "./utils/mock-dashboard-snapshot"; +import { revealDeferredSections } from "./utils/deferred-sections"; type AxeViolation = Awaited< ReturnType @@ -41,6 +42,8 @@ type RouteCase = { name: string; path: string; painted: (page: Page) => Locator; + /** Runs after navigation, before the painted gate (e.g. mount deferred sections). */ + prepare?: (page: Page) => Promise; }; const THEMES: readonly Theme[] = ["light", "dark"]; @@ -325,6 +328,7 @@ async function visitAndScan( ): Promise { return test.step(`${theme} ${routeCase.name}`, async () => { await page.goto(routeCase.path, { waitUntil: "domcontentloaded" }); + await routeCase.prepare?.(page); return scanPaintedState( page, theme, @@ -773,6 +777,8 @@ const INSIGHTS_ROUTES: readonly RouteCase[] = [ { name: "/insights overview", path: "/insights", + // Scan the whole overview, the sections that mount near the viewport too. + prepare: revealDeferredSections, // The vitals grid, settled. The scores strip is not a stable gate: it // unmounts for an account with no score yet, and since v1.39 it no longer // keeps an empty cell for a cycle ring that has no dial to draw. diff --git a/e2e/mobile-horizontal-overflow.spec.ts b/e2e/mobile-horizontal-overflow.spec.ts index 20e57df8d..c955b9fc9 100644 --- a/e2e/mobile-horizontal-overflow.spec.ts +++ b/e2e/mobile-horizontal-overflow.spec.ts @@ -9,6 +9,7 @@ import { PHONE_WIDTHS, settleForOverflowMeasurement, } from "./utils/horizontal-overflow"; +import { revealDeferredSections } from "./utils/deferred-sections"; /** * The content-heavy routes must not scroll sideways at any phone width. @@ -73,6 +74,9 @@ test.describe("content-heavy routes have no horizontal page scroll at phone widt for (const width of PHONE_WIDTHS) { await page.setViewportSize({ width, height: 851 }); await page.goto(routeCase.path, { waitUntil: "domcontentloaded" }); + // Measure the whole page, the overview sections that wait for the + // viewport included, or a wide card far down would go unmeasured. + if (routeCase.path === "/insights") await revealDeferredSections(page); await expect(page.locator(routeCase.ready).first()).toBeVisible(); await settleForOverflowMeasurement(page); await expectNoHorizontalOverflow(page, `${routeCase.path} @${width}px`); @@ -93,6 +97,7 @@ test.describe("content-heavy routes have no horizontal page scroll at phone widt await mockPopulatedInsights(page); await page.setViewportSize({ width: 390, height: 851 }); await page.goto("/insights", { waitUntil: "domcontentloaded" }); + await revealDeferredSections(page); const signals = page.locator('[data-slot="coincident-deviation-section"]'); await expect(signals).toBeVisible(); @@ -129,6 +134,7 @@ test.describe("content-heavy routes have no horizontal page scroll at phone widt await mockPopulatedInsights(page); await page.setViewportSize({ width: 390, height: 851 }); await page.goto("/insights", { waitUntil: "domcontentloaded" }); + await revealDeferredSections(page); // Wait on the CAPTION, not the heading: the card first paints a skeleton // whose heading carries no action slot, then swaps to the resolved shell. @@ -179,6 +185,7 @@ test.describe("content-heavy routes have no horizontal page scroll at phone widt await mockPopulatedInsights(page); await page.setViewportSize({ width: 390, height: 851 }); await page.goto("/insights", { waitUntil: "domcontentloaded" }); + await revealDeferredSections(page); // Wait for the same readiness signal and settle step every route case // above uses. This was the one test in the file that measured and injected // straight after `domcontentloaded`, against a tree that was still diff --git a/e2e/modules-off.spec.ts b/e2e/modules-off.spec.ts index ceb79df73..2995a8c30 100644 --- a/e2e/modules-off.spec.ts +++ b/e2e/modules-off.spec.ts @@ -25,6 +25,7 @@ import type { Page } from "@playwright/test"; import { MODULES_STORAGE_STATE_PATH } from "./setup/global-setup"; import { modulesSeedState } from "./setup/modules-fixture"; import { expect, test } from "./setup/test"; +import { revealDeferredSections } from "./utils/deferred-sections"; test.use({ storageState: MODULES_STORAGE_STATE_PATH }); test.describe.configure({ mode: "serial" }); @@ -130,6 +131,8 @@ test("mood and medications, switched off, leave every surface they own", async ( await expect( page.locator('[data-slot="insights-tab-strip-pill"][href="/insights"]'), ).toBeVisible({ timeout: 20_000 }); + // The trends row sits below the sections that mount with the page. + await revealDeferredSections(page); await expect(page.locator('[data-slot="trends-row"]')).toBeVisible(); await expect(page.locator(moodPill)).toHaveCount(0); await expect(page.locator(medsPill)).toHaveCount(0); diff --git a/e2e/utils/deferred-sections.ts b/e2e/utils/deferred-sections.ts new file mode 100644 index 000000000..0ca4748ea --- /dev/null +++ b/e2e/utils/deferred-sections.ts @@ -0,0 +1,38 @@ +import { expect, type Page } from "@playwright/test"; + +/** + * Mount every overview section that waits for the viewport. + * + * Sections below the first screens of `/insights` render as a 1 px + * `[data-deferred-section]` sentinel until they come near the visible area + * (`src/components/insights/defer-until-near.tsx`). A spec that asserts on + * one of them, or measures or scans the whole page, scrolls each sentinel + * into view first, the way a reader would, until none is left. Polled, since + * the observers only exist once the page has hydrated. + */ +export async function revealDeferredSections(page: Page): Promise { + // The sentinels render with the overview itself; until the hero is there, + // "no sentinel" only means the page has not rendered yet. + await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeAttached({ + timeout: 20_000, + }); + const sentinels = page.locator("[data-deferred-section]"); + await expect + .poll( + async () => { + const remaining = await sentinels.count(); + if (remaining > 0) { + await sentinels + .first() + .scrollIntoViewIfNeeded({ timeout: 1_000 }) + .catch(() => { + // Mounted between the count and the scroll. + }); + } + return remaining; + }, + { timeout: 20_000 }, + ) + .toBe(0); + await page.evaluate(() => document.querySelector("main")?.scrollTo(0, 0)); +} diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx index 44f946c30..375088b05 100644 --- a/src/app/insights/page-client.tsx +++ b/src/app/insights/page-client.tsx @@ -30,6 +30,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { HeroStrip } from "@/components/insights/hero-strip"; import { AiSetupHint } from "@/components/insights/ai-setup-hint"; +import { DeferUntilNear } from "@/components/insights/defer-until-near"; import { OverviewSectionBoundary } from "@/components/insights/overview-section-boundary"; import { isSurfaceVisible } from "@/lib/modules/surface"; import { useCycleRingDial } from "@/components/cycle/use-cycle"; @@ -236,6 +237,19 @@ const CycleRingTile = dynamic( { ssr: false }, ); +/** + * How many overview sections, in the account's own order, mount with the + * page. The rest mount as they come near the visible area + * (`DeferUntilNear`). On entry the overview used to fire every section's + * reads at once (about twenty requests on a seeded account, trends, + * narrative, cycle, signals, rhythm events, health status, breathing, labs + * and ECG included), and the server queued them behind each other, so the + * hero and the first sections waited on cards far below the fold. The first + * three always sit within the first two screens; everything after them is + * further down on every viewport measured (phone and desktop). + */ +const EAGER_OVERVIEW_SECTIONS = 3; + /** * v1.4.25 W4d — Insights mother page. * @@ -548,6 +562,11 @@ export default function InsightsPageClient() { const orderedSectionIds = orderedVisibleSectionIds(layout); const everySectionHidden = orderedSectionIds.length === 0; + // Sections whose gate is off resolve to `null` and take no slot, so the + // eager / deferred split below counts only what actually renders. + const renderedSectionIds = orderedSectionIds.filter( + (id) => SECTION_REGISTRY[id] !== null, + ); return ( // v1.12.7 (L3) — one consistent vertical rhythm down the overview. The @@ -621,12 +640,18 @@ export default function InsightsPageClient() { registry, so the `space-y-6` rhythm closes the gap with no hole. Each registry node is wrapped in a keyed Fragment so React keeps a stable identity across a reorder. */ - orderedSectionIds.map((id) => ( + renderedSectionIds.map((id, index) => ( - {SECTION_REGISTRY[id] === null ? null : ( + {index < EAGER_OVERVIEW_SECTIONS ? ( {SECTION_REGISTRY[id]} + ) : ( + + + {SECTION_REGISTRY[id]} + + )} )) diff --git a/src/components/insights/__tests__/defer-until-near.test.tsx b/src/components/insights/__tests__/defer-until-near.test.tsx new file mode 100644 index 000000000..23ac8199b --- /dev/null +++ b/src/components/insights/__tests__/defer-until-near.test.tsx @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { DeferUntilNear } from "../defer-until-near"; + +/** + * The first paint of a deferred slot is the sentinel alone: the children, + * and so every read they own, wait until the slot nears the visible area. + * The mounted state is exercised in a real browser by the e2e specs that + * reveal the Insights overview (`e2e/utils/deferred-sections.ts`). + */ +describe("", () => { + it("paints only a 1 px sentinel, never the children, before it is near", () => { + const html = renderToStaticMarkup( + +
reads on mount
+
, + ); + expect(html).toBe( + '', + ); + }); +}); diff --git a/src/components/insights/defer-until-near.tsx b/src/components/insights/defer-until-near.tsx new file mode 100644 index 000000000..4a55338b7 --- /dev/null +++ b/src/components/insights/defer-until-near.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect, useRef, useState, type ReactNode } from "react"; + +/** + * How far ahead of the visible area a deferred section mounts. Roughly half a + * phone screen: far enough that a section is usually painted before it + * scrolls in, close enough that a visit that never scrolls never asks for it. + */ +export const DEFER_UNTIL_NEAR_MARGIN = "400px 0px"; + +/** The nearest ancestor that scrolls vertically, or `null` for the viewport. */ +function scrollRoot(el: HTMLElement): Element | null { + for (let node = el.parentElement; node; node = node.parentElement) { + const overflowY = getComputedStyle(node).overflowY; + if (overflowY === "auto" || overflowY === "scroll") return node; + } + return null; +} + +/** + * Mount `children` only once their slot comes near the visible area. + * + * Until then a 1 px sentinel holds the slot, so a section that owns its own + * reads does not send them on page entry. Observed against the nearest + * scroll container (the app shell scrolls inside `
`, not the window), + * since a root margin only widens the root and an observer on the viewport + * would not see a section until it was already on screen. + * + * Once mounted the children stay mounted, and the sentinel leaves no wrapper + * behind: the rendered tree is the same as an eager mount. Without + * `IntersectionObserver` the children mount on the next task. + */ +export function DeferUntilNear({ + id, + children, +}: { + /** Names the slot for tests: `data-deferred-section`. */ + id: string; + children: ReactNode; +}) { + const [near, setNear] = useState(false); + const sentinelRef = useRef(null); + + useEffect(() => { + if (near) return; + const el = sentinelRef.current; + if (!el) return; + if (typeof IntersectionObserver === "undefined") { + // No observer to wait on: mount on the next task instead of never. + const timer = setTimeout(() => setNear(true)); + return () => clearTimeout(timer); + } + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) setNear(true); + }, + { root: scrollRoot(el), rootMargin: DEFER_UNTIL_NEAR_MARGIN }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [near]); + + if (near) return <>{children}; + return ( +