From 4916a29bbe118166914563d5a93bbdc52694c88b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 15:48:13 +0200 Subject: [PATCH] perf(insights): mount below-the-fold overview sections near the viewport Entering the Insights overview fired every section's reads at once, about two dozen requests on a seeded account, and the server worked through them in a queue: the hero and the first sections waited behind trends, the period review, cycle, signals, rhythm events, health status, breathing, labs and ECG, all far below the fold. The first three sections in the account's own order still mount with the page. Every later one renders a 1 px sentinel until it comes within 400 px of the visible area of the shell's scroll container, then mounts and fetches; once mounted it stays. Measured on a seeded account under a 4x CPU slowdown: 24 -> 13 requests on entry, the entry network span 1.3-1.8 s -> 0.5-0.9 s, LCP about 1.35 s -> 0.9 s, blocking time about 550 ms -> 200 ms. Scrolling to the end still loads all 25 and renders all ten sections. Specs that assert on, measure or scan the lower sections scroll them in first through a shared helper, so their coverage is unchanged. --- e2e/a11y.spec.ts | 6 ++ e2e/mobile-horizontal-overflow.spec.ts | 7 ++ e2e/modules-off.spec.ts | 3 + e2e/utils/deferred-sections.ts | 38 ++++++++++ src/app/insights/page-client.tsx | 29 +++++++- .../__tests__/defer-until-near.test.tsx | 23 ++++++ src/components/insights/defer-until-near.tsx | 73 +++++++++++++++++++ 7 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 e2e/utils/deferred-sections.ts create mode 100644 src/components/insights/__tests__/defer-until-near.test.tsx create mode 100644 src/components/insights/defer-until-near.tsx 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 ( +