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/chart-text-measurement-host.spec.ts b/e2e/chart-text-measurement-host.spec.ts new file mode 100644 index 000000000..8da0ea448 --- /dev/null +++ b/e2e/chart-text-measurement-host.spec.ts @@ -0,0 +1,104 @@ +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * Recharts measures every tick label in one shared off-screen span + * (`#recharts_measurement_span`). Under `` each read cost a + * document-wide style recalc, because the stylesheet's descendant-subject + * `:has()` rules leave Chromium no way to scope the invalidation; on a + * seeded dashboard that was about 1.8 s of main-thread time under a 4x CPU + * slowdown. The chart runtime now parks the span in a contained host under + * `` (`src/components/charts/text-measurement-host.ts`). + * + * Pinned here, in a real engine: + * - the span Recharts uses is the one in the host, not a second one it + * created under ``, including after a client-side round trip; + * - every string the charts drew measures exactly as it does under + * ``, so the move cannot change a single tick or label. + */ +test.describe("chart text measurement host", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + async function assertHostInPlace(page: import("@playwright/test").Page) { + const placement = await page.evaluate(() => { + const spans = document.querySelectorAll("#recharts_measurement_span"); + const span = spans[0] as HTMLElement | undefined; + return { + count: spans.length, + hostSlot: span?.parentElement?.dataset.slot ?? null, + hostParent: span?.parentElement?.parentElement?.tagName ?? null, + }; + }); + expect(placement).toEqual({ + count: 1, + hostSlot: "chart-text-measurement-host", + hostParent: "HTML", + }); + } + + test("the dashboard charts measure text outside , with identical results", async ({ + page, + }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.locator(".recharts-wrapper").first()).toBeVisible({ + timeout: 20_000, + }); + await assertHostInPlace(page); + + const parity = await page.evaluate(() => { + const host = document.getElementById( + "recharts_measurement_span", + ) as HTMLElement; + const reference = document.createElement("span"); + document.body.appendChild(reference); + // Recharts' own measurement style (`getStringSize`). + const base = { + position: "absolute", + top: "-20000px", + left: "0", + padding: "0", + margin: "0", + border: "none", + whiteSpace: "pre", + }; + const strings = [ + ...new Set( + [...document.querySelectorAll(".recharts-wrapper text")] + .map((el) => el.textContent ?? "") + .filter(Boolean), + ), + ]; + const mismatches: string[] = []; + for (const fontSize of ["10px", "12px", "14px"]) { + for (const text of strings) { + Object.assign(host.style, base, { fontSize }); + Object.assign(reference.style, base, { fontSize }); + host.textContent = text; + reference.textContent = text; + const a = host.getBoundingClientRect(); + const b = reference.getBoundingClientRect(); + if (a.width !== b.width || a.height !== b.height) { + mismatches.push(`${text}@${fontSize}`); + } + } + } + reference.remove(); + return { measured: strings.length, mismatches }; + }); + // A chart with no labels would make the parity check vacuous. + expect(parity.measured).toBeGreaterThan(0); + expect(parity.mismatches).toEqual([]); + + // A client-side round trip re-renders the shell; the host must survive + // it, or Recharts quietly recreates its span under . + await page.locator('a[href="/insights"]:visible').first().click(); + await page.waitForURL((url) => url.pathname === "/insights"); + await page.locator('a[href="/"]:visible').first().click(); + await page.waitForURL((url) => url.pathname === "/"); + await expect(page.locator(".recharts-wrapper").first()).toBeVisible({ + timeout: 20_000, + }); + await assertHostInPlace(page); + }); +}); 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/sidebar-expand-no-scrollbar.spec.ts b/e2e/sidebar-expand-no-scrollbar.spec.ts new file mode 100644 index 000000000..71b8d73e8 --- /dev/null +++ b/e2e/sidebar-expand-no-scrollbar.spec.ts @@ -0,0 +1,139 @@ +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * Expanding the sidebar must never flash a scrollbar on the nav list. + * + * The rail animates `width` from `w-16` to `w-64`. When the labels laid out + * against the growing width, every row overran the 48 px nav and the + * multi-word labels wrapped, so the nav's `overflow-y-auto` painted a + * vertical and a horizontal scrollbar until the transition ended. The fix + * lays the column out at its final width from the first frame and lets the + * rail clip it (see `sidebar-column` in `src/components/layout/sidebar-nav.tsx`). + * + * This samples the nav on every animation frame of a real expand and a real + * collapse, so it fails on the overflow itself rather than on a class name. + * Desktop only: the sidebar is hidden below `md`. + */ +test.describe("sidebar width transition", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(({}, testInfo) => { + test.skip( + testInfo.project.name === "chromium-mobile", + "global sidebar is desktop-only (md+)", + ); + }); + + test("the nav list never overflows while the rail expands or collapses", async ({ + page, + }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + const toggle = page.locator('[data-slot="sidebar-collapse-toggle"]'); + await expect(toggle).toBeVisible(); + const nav = page.locator("aside nav").first(); + await expect(nav).toBeVisible(); + + async function sampleToggle() { + const heightBefore = await nav.evaluate((el) => el.scrollHeight); + await page.evaluate(() => { + const el = document.querySelector("aside nav") as HTMLElement; + const samples: { sw: number; cw: number; sh: number }[] = []; + (window as unknown as { __navSamples: typeof samples }).__navSamples = + samples; + const t0 = performance.now(); + const tick = () => { + samples.push({ + sw: el.scrollWidth, + cw: el.clientWidth, + sh: el.scrollHeight, + }); + if (performance.now() - t0 < 600) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); + await toggle.click(); + await page.waitForTimeout(700); + const samples = await page.evaluate( + () => + ( + window as unknown as { + __navSamples: { sw: number; cw: number; sh: number }[]; + } + ).__navSamples, + ); + const heightAfter = await nav.evaluate((el) => el.scrollHeight); + // Expanded rows are taller than rail rows, so the list may legitimately + // be either rest height while the width moves, never more. + return { samples, restHeight: Math.max(heightBefore, heightAfter) }; + } + + // Two round trips, so the run covers both directions whichever state + // the stored preference started in. + for (let i = 0; i < 4; i++) { + const { samples, restHeight } = await sampleToggle(); + // A 200 ms transition yields several frames; too few means the + // sampler never ran and the assertions below would prove nothing. + expect(samples.length).toBeGreaterThan(5); + for (const s of samples) { + // No horizontal overflow: no row is wider than the nav. + expect(s.sw).toBeLessThanOrEqual(s.cw + 1); + } + // No vertical growth: no label wraps mid-transition, so the list is + // never taller than at rest and cannot summon a scrollbar. + const tallest = Math.max(...samples.map((s) => s.sh)); + expect(tallest).toBeLessThanOrEqual(restHeight + 1); + } + }); + + test("on a short window the list scrolls behind a visible edge, never under the footer", async ({ + page, + }) => { + // Short enough that the list cannot fit between the logo band and the + // footer group. The rail used to cut the last visible entry off right at + // the Collapse row with nothing marking the edge, which read as the + // footer covering the entry. + await page.setViewportSize({ width: 1280, height: 600 }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + const nav = page.locator("aside nav").first(); + const footer = page.locator('aside [data-slot="sidebar-footer"]'); + await expect(nav).toBeVisible(); + await expect(footer).toHaveAttribute("data-nav-overflows", "true"); + const borderTop = await footer.evaluate( + (el) => getComputedStyle(el).borderTopWidth, + ); + expect(borderTop).toBe("1px"); + + // Scrolled to its end, the last entry sits wholly inside the list, + // above the footer, and is the element under its own centre. + const end = await nav.evaluate((el) => { + el.scrollTop = el.scrollHeight; + const links = [...el.querySelectorAll("a")]; + const last = links[links.length - 1] as HTMLElement; + const r = last.getBoundingClientRect(); + const navBox = el.getBoundingClientRect(); + const footerTop = ( + document.querySelector('aside [data-slot="sidebar-footer"]') as Element + ).getBoundingClientRect().top; + const hit = document.elementFromPoint( + r.left + r.width / 2, + r.top + r.height / 2, + ); + return { + insideList: r.bottom <= navBox.bottom + 0.5, + aboveFooter: r.bottom <= footerTop + 0.5, + hitsItself: last.contains(hit), + }; + }); + expect(end).toEqual({ + insideList: true, + aboveFooter: true, + hitsItself: true, + }); + + // With room to spare there is no edge: the default rail is unchanged. + await page.setViewportSize({ width: 1280, height: 1400 }); + await expect(footer).not.toHaveAttribute("data-nav-overflows", "true"); + }); +}); 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/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index d46bb5b8c..3b23a8804 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -320,7 +320,7 @@ const UNPUBLISHED: Readonly> = { "/api/monitoring/glitchtip": { kind: "internalOps", methods: ["POST"] }, "/api/monitoring/settings": { kind: "internalOps", methods: ["GET"] }, "/api/monitoring/umami-script": { kind: "internalOps", methods: ["GET"] }, - "/api/send": { kind: "internalOps", methods: ["POST"] }, + "/api/monitoring/umami/{path}": { kind: "internalOps", methods: ["POST"] }, "/api/mcp/oauth/authorize": { kind: "mcpTransport", methods: ["GET", "POST"], diff --git a/src/app/api/admin/monitoring/umami-test/route.ts b/src/app/api/admin/monitoring/umami-test/route.ts index e3a9d6fc1..52db57a1c 100644 --- a/src/app/api/admin/monitoring/umami-test/route.ts +++ b/src/app/api/admin/monitoring/umami-test/route.ts @@ -4,6 +4,10 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; import { apiError, apiSuccess } from "@/lib/api-response"; import { getPublicMonitoringSettings } from "@/lib/monitoring-settings"; +import { + resolveUmamiCollectPath, + resolveUmamiSendUrls, +} from "@/lib/monitoring/umami"; import { safeFetch } from "@/lib/safe-fetch"; export const dynamic = "force-dynamic"; @@ -12,30 +16,6 @@ function redact(text: string): string { return text.replace(/https?:\/\/\S+/gi, "[url]"); } -function resolveUmamiSendUrls(scriptUrl: string | null): string[] { - if (!scriptUrl) return []; - try { - const parsed = new URL(scriptUrl); - const origin = parsed.origin; - const pathSegments = parsed.pathname.split("/").filter(Boolean); - const segments = [...pathSegments]; - - if (segments.length > 0 && segments[segments.length - 1]?.includes(".")) { - segments.pop(); - } - - const prefix = segments.length > 0 ? `/${segments.join("/")}` : ""; - const candidates = [ - `${origin}${prefix}/api/send`, - `${origin}/api/send`, - `${origin}/umami/api/send`, - ]; - return Array.from(new Set(candidates)); - } catch { - return []; - } -} - function resolveAppUrl(request: NextRequest): URL { const configured = envValue("APP_URL") ?? @@ -63,7 +43,13 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Umami script URL and website ID must be configured", 422); } - const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl); + // The collect path is compiled into the operator's tracker build + // (`COLLECT_API_ENDPOINT`), so the test posts where the tracker would. + const collectPath = await resolveUmamiCollectPath(settings.umamiScriptUrl); + if (collectPath === null) { + return apiError("Umami script could not be fetched", 502); + } + const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl, collectPath); if (targetUrls.length === 0) { return apiError("Umami script URL is invalid", 422); } diff --git a/src/app/api/monitoring/umami-script/route.ts b/src/app/api/monitoring/umami-script/route.ts index 4482c9c71..d625bb995 100644 --- a/src/app/api/monitoring/umami-script/route.ts +++ b/src/app/api/monitoring/umami-script/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { getPublicMonitoringSettings } from "@/lib/monitoring-settings"; -import { safeFetch } from "@/lib/safe-fetch"; +import { fetchUmamiScript } from "@/lib/monitoring/umami"; export const dynamic = "force-dynamic"; @@ -26,35 +26,14 @@ export const GET = apiHandler(async () => { }); } - // Fail-soft: an unreachable / slow / rebinding-refused upstream must - // degrade to the noop script, not 500 the proxy route — `safeFetch` - // THROWS on its 15 s timeout and on the connect-time public-host - // refusal, and an uncaught throw here surfaced as a 500 on every - // mount whenever the operator's Umami host was down or unresolvable. - let response: Response; - try { - response = await safeFetch( - settings.umamiScriptUrl, - { - next: { revalidate: 3600 }, - }, - // Operator-configured host — pin the connect-time IP so a low-TTL - // DNS record cannot rebind the fetch at an internal range between - // the input-time `isPublicUrl` accept and the socket connect. - { requirePublicHost: true }, - ); - } catch (err) { - // Fail-soft stays a 200, but the failure must stay observable: - // a proper action name keeps the degraded state visible on the - // wide-event dashboards instead of hiding behind a bare meta flag. + // Fail-soft: an unreachable / slow / rebinding-refused upstream degrades + // to the noop script, never a 500 on every mount. The failure stays + // observable under its own action name on the wide-event dashboards. + const fetched = await fetchUmamiScript(settings.umamiScriptUrl); + if (!fetched.ok) { annotate({ action: { name: "monitoring.umami_script.fetch_failed" }, - meta: { - reason: - err instanceof Error - ? err.message.slice(0, 200) - : String(err).slice(0, 200), - }, + meta: { reason: fetched.reason }, }); return new NextResponse(NOOP_SCRIPT, { status: 200, @@ -64,18 +43,8 @@ export const GET = apiHandler(async () => { }, }); } - if (!response.ok) { - return new NextResponse(NOOP_SCRIPT, { - status: 200, - headers: { - "Content-Type": "application/javascript; charset=utf-8", - "Cache-Control": "public, max-age=60", - }, - }); - } - const script = await response.text(); - return new NextResponse(script, { + return new NextResponse(fetched.script, { status: 200, headers: { "Content-Type": "application/javascript; charset=utf-8", diff --git a/src/app/api/monitoring/umami/[...path]/__tests__/route.test.ts b/src/app/api/monitoring/umami/[...path]/__tests__/route.test.ts new file mode 100644 index 000000000..8093f0fe0 --- /dev/null +++ b/src/app/api/monitoring/umami/[...path]/__tests__/route.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// safeFetch's requirePublicHost path runs through undici's own `fetch` +// (version-locked with its dispatcher). Delegate it to the global `fetch` +// stub these tests install so the interception still applies. +vi.mock("undici", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetch: (input: unknown, init?: unknown) => + (globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)( + input, + init, + ), + }; +}); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +vi.mock("@/lib/monitoring-settings", () => ({ + getPublicMonitoringSettings: vi.fn(), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), +})); + +import { POST } from "../route"; +import { getPublicMonitoringSettings } from "@/lib/monitoring-settings"; +import { resetUmamiCollectPathCache } from "@/lib/monitoring/umami"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const SCRIPT_URL = "https://analytics.example.com/script.js"; + +/** A built tracker's endpoint template, as Umami compiles it. */ +function tracker(collectPath: string): string { + return `!function(){const R=\`\${(x||""||u.src.split("/").slice(0,-1).join("/")).replace(/\\/$/,"")}${collectPath}\`;}();`; +} + +function post(path: string[], ip = "203.0.113.11") { + return POST( + new NextRequest(`http://localhost/api/monitoring/umami/${path.join("/")}`, { + method: "POST", + body: JSON.stringify({ type: "event" }), + headers: { "content-type": "application/json", "x-real-ip": ip }, + }), + { params: Promise.resolve({ path }) }, + ); +} + +/** Upstream: the tracker script on GET, `status` on the event POST. */ +function upstream(collectPath: string, status = 202) { + vi.mocked(fetch).mockImplementation(async (input, init) => { + const url = String(input instanceof Request ? input.url : input); + if (url === SCRIPT_URL && (!init?.method || init.method === "GET")) { + return new Response(tracker(collectPath), { status: 200 }); + } + return new Response(null, { status }); + }); +} + +function eventPosts(): string[] { + return vi + .mocked(fetch) + .mock.calls.filter(([, init]) => init?.method === "POST") + .map(([input]) => String(input)); +} + +beforeEach(() => { + vi.resetAllMocks(); + resetUmamiCollectPathCache(); + vi.stubGlobal("fetch", vi.fn()); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 119, + resetAt: Date.now() + 60_000, + } as never); + vi.mocked(getPublicMonitoringSettings).mockResolvedValue({ + umamiEnabled: true, + umamiScriptUrl: SCRIPT_URL, + umamiWebsiteId: "site-1", + } as never); +}); + +describe("POST /api/monitoring/umami/[...path]", () => { + it("rate-limits the public Umami proxy by client IP before anything else", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + } as never); + + const response = await post(["api", "send"], "203.0.113.10"); + + expect(response.status).toBe(429); + expect(checkRateLimit).toHaveBeenCalledWith( + "umami-proxy:203.0.113.10", + 120, + 60 * 1000, + ); + expect(getPublicMonitoringSettings).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("forwards a default build's /api/send to the Umami host", async () => { + upstream("/api/send"); + const response = await post(["api", "send"]); + expect(response.status).toBe(202); + expect(eventPosts()).toEqual(["https://analytics.example.com/api/send"]); + }); + + it("forwards a renamed collect endpoint to the same path on the Umami host", async () => { + // A build with COLLECT_API_ENDPOINT=/api/insight posts to + // `/api/insight`. Before the proxy prefix, that landed on + // the app's own origin and 404'd with a full page on every page view. + upstream("/api/insight"); + const response = await post(["api", "insight"]); + expect(response.status).toBe(202); + expect(eventPosts()).toEqual(["https://analytics.example.com/api/insight"]); + }); + + it("refuses any path that is not the tracker's collect path, with an empty body", async () => { + upstream("/api/insight"); + const response = await post(["api", "websites"]); + expect(response.status).toBe(404); + expect(await response.text()).toBe(""); + expect(eventPosts()).toEqual([]); + }); + + it("learns the collect path once, not on every event", async () => { + upstream("/api/insight"); + await post(["api", "insight"]); + await post(["api", "insight"]); + const scriptFetches = vi + .mocked(fetch) + .mock.calls.filter(([input]) => String(input) === SCRIPT_URL); + expect(scriptFetches).toHaveLength(1); + expect(eventPosts()).toHaveLength(2); + }); + + it("answers an empty 502 when the tracker cannot be read", async () => { + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 500 })); + const response = await post(["api", "send"]); + expect(response.status).toBe(502); + expect(await response.text()).toBe(""); + expect(eventPosts()).toEqual([]); + }); + + it("answers 204 without touching the network when Umami is off", async () => { + vi.mocked(getPublicMonitoringSettings).mockResolvedValue({ + umamiEnabled: false, + umamiScriptUrl: SCRIPT_URL, + umamiWebsiteId: "site-1", + } as never); + const response = await post(["api", "send"]); + expect(response.status).toBe(204); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/monitoring/umami/[...path]/route.ts b/src/app/api/monitoring/umami/[...path]/route.ts new file mode 100644 index 000000000..70e83ad3b --- /dev/null +++ b/src/app/api/monitoring/umami/[...path]/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { apiError, getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { getPublicMonitoringSettings } from "@/lib/monitoring-settings"; +import { + resolveUmamiCollectPath, + resolveUmamiSendUrls, +} from "@/lib/monitoring/umami"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { safeFetch } from "@/lib/safe-fetch"; + +export const dynamic = "force-dynamic"; + +/** An empty answer: a refused event must not cost the tracker a 404 page. */ +function empty(status: number): NextResponse { + return new NextResponse(null, { status }); +} + +/** + * Same-origin proxy for the browser's Umami tracker. + * + * The tracker is loaded with `data-host-url` set to `UMAMI_PROXY_PREFIX`, so + * it posts to ``, where the collect path is whatever + * the operator's Umami build calls its endpoint (`/api/send` by default). + * The path after the prefix must be exactly the collect path compiled into + * the configured tracker; anything else is refused, so the proxy forwards + * events and nothing else to the operator's host. + */ +export const POST = apiHandler( + async ( + request: NextRequest, + context: { params: Promise<{ path: string[] }> }, + ) => { + annotate({ action: { name: "umami.proxy" } }); + + const ip = getClientIp(request) ?? "unknown"; + const rl = await checkRateLimit(`umami-proxy:${ip}`, 120, 60 * 1000); + if (!rl.allowed) return apiError("Rate limit exceeded", 429); + + const settings = await getPublicMonitoringSettings(); + if ( + !settings.umamiEnabled || + !settings.umamiScriptUrl || + !settings.umamiWebsiteId + ) { + return empty(204); + } + + const { path } = await context.params; + const requested = `/${path.join("/")}`; + const collectPath = await resolveUmamiCollectPath(settings.umamiScriptUrl); + if (collectPath === null) { + annotate({ + action: { name: "umami.proxy.script_unavailable" }, + }); + return empty(502); + } + if (requested !== collectPath) { + annotate({ + action: { name: "umami.proxy.path_refused" }, + meta: { requested: requested.slice(0, 128), collectPath }, + }); + return empty(404); + } + + const targetUrls = resolveUmamiSendUrls( + settings.umamiScriptUrl, + collectPath, + ); + if (targetUrls.length === 0) return empty(204); + + const body = await request.arrayBuffer(); + // Cap the proxied body at 64 KB. + if (body.byteLength > 65536) return empty(413); + + let lastStatus = 404; + for (const targetUrl of targetUrls) { + let upstream: Response; + try { + upstream = await safeFetch( + targetUrl, + { + method: "POST", + headers: { + "content-type": + request.headers.get("content-type") || "application/json", + "user-agent": + request.headers.get("user-agent") || "healthlog-proxy", + }, + body, + cache: "no-store", + }, + // Operator-configured Umami host: pin the connect-time IP. + { requirePublicHost: true }, + ); + } catch { + annotate({ action: { name: "umami.proxy.upstream_failed" } }); + return empty(502); + } + lastStatus = upstream.status; + // Only a path miss moves on to the next candidate. + if (upstream.status !== 404) return empty(upstream.status); + } + return empty(lastStatus); + }, +); diff --git a/src/app/api/send/__tests__/route.test.ts b/src/app/api/send/__tests__/route.test.ts deleted file mode 100644 index 874cca183..000000000 --- a/src/app/api/send/__tests__/route.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -// safeFetch's requirePublicHost path runs through undici's own `fetch` -// (version-locked with its dispatcher). Delegate it to the global `fetch` -// stub these tests install so the existing interception still applies. -vi.mock("undici", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - fetch: (input: unknown, init?: unknown) => - (globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)( - input, - init, - ), - }; -}); - -vi.mock("@/lib/api-handler", () => ({ - apiHandler: unknown>(fn: T) => fn, -})); - -vi.mock("@/lib/logging/context", () => ({ - annotate: vi.fn(), -})); - -vi.mock("@/lib/monitoring-settings", () => ({ - getPublicMonitoringSettings: vi.fn(), -})); - -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn(), -})); - -import { POST } from "../route"; -import { getPublicMonitoringSettings } from "@/lib/monitoring-settings"; -import { checkRateLimit } from "@/lib/rate-limit"; - -beforeEach(() => { - vi.resetAllMocks(); - vi.stubGlobal("fetch", vi.fn()); -}); - -describe("POST /api/send", () => { - it("rate-limits the public Umami proxy by client IP", async () => { - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: false, - remaining: 0, - resetAt: Date.now() + 60_000, - } as never); - - const response = await POST( - new NextRequest("http://localhost/api/send", { - method: "POST", - body: JSON.stringify({ type: "event" }), - headers: { - "content-type": "application/json", - "x-forwarded-for": "203.0.113.10", - }, - }), - ); - - expect(response.status).toBe(429); - expect(checkRateLimit).toHaveBeenCalledWith( - "umami-proxy:203.0.113.10", - 120, - 60 * 1000, - ); - expect(getPublicMonitoringSettings).not.toHaveBeenCalled(); - expect(fetch).not.toHaveBeenCalled(); - }); - - it("continues to proxy when the request is within quota", async () => { - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 119, - resetAt: Date.now() + 60_000, - } as never); - vi.mocked(getPublicMonitoringSettings).mockResolvedValue({ - umamiEnabled: true, - umamiScriptUrl: "https://analytics.example.com/script.js", - umamiWebsiteId: "site-1", - } as never); - vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 202 })); - - const response = await POST( - new NextRequest("http://localhost/api/send", { - method: "POST", - body: JSON.stringify({ type: "event" }), - headers: { - "content-type": "application/json", - "x-real-ip": "203.0.113.11", - }, - }), - ); - - expect(response.status).toBe(202); - expect(fetch).toHaveBeenCalledWith( - "https://analytics.example.com/api/send", - expect.objectContaining({ method: "POST" }), - ); - }); -}); diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts deleted file mode 100644 index e8f8e13af..000000000 --- a/src/app/api/send/route.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { apiHandler } from "@/lib/api-handler"; -import { apiError, getClientIp } from "@/lib/api-response"; -import { annotate } from "@/lib/logging/context"; -import { getPublicMonitoringSettings } from "@/lib/monitoring-settings"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { isPublicUrl } from "@/lib/validations/notifications"; -import { safeFetch } from "@/lib/safe-fetch"; - -export const dynamic = "force-dynamic"; - -// Use the canonical `isPublicUrl` guard rather than a hand-rolled -// allowlist. The previous local implementation predated the central -// helper and was missing CGNAT (100.64/10), IPv6 ULA (fc00::/7), the -// octal/hex/decimal IPv4 alt-notations, and the IPv4-mapped IPv6 -// shapes — every one of which the shared helper catches. Dev mode is -// no longer special-cased here: a localhost-or-RFC1918 Umami URL was -// never a valid configuration anyway (the proxy ships the request to -// the configured host with the visitor's user-agent + body, which -// only makes sense against an externally-reachable analytics target). -function isAllowedUmamiUrl(url: string): boolean { - return isPublicUrl(url); -} - -function resolveUmamiSendUrls(scriptUrl: string | null): string[] { - if (!scriptUrl) return []; - try { - const parsed = new URL(scriptUrl); - const origin = parsed.origin; - const pathSegments = parsed.pathname.split("/").filter(Boolean); - const segments = [...pathSegments]; - - // Remove script file segment (e.g. script.js, umami.js) if present. - if (segments.length > 0 && segments[segments.length - 1]?.includes(".")) { - segments.pop(); - } - - const prefix = segments.length > 0 ? `/${segments.join("/")}` : ""; - const candidates = [ - `${origin}${prefix}/api/send`, - `${origin}/api/send`, - `${origin}/umami/api/send`, - ]; - - return Array.from(new Set(candidates.filter(isAllowedUmamiUrl))); - } catch { - return []; - } -} - -export const POST = apiHandler(async (request: NextRequest) => { - annotate({ action: { name: "umami.proxy" } }); - - const ip = getClientIp(request) ?? "unknown"; - const rl = await checkRateLimit(`umami-proxy:${ip}`, 120, 60 * 1000); - if (!rl.allowed) return apiError("Rate limit exceeded", 429); - - const settings = await getPublicMonitoringSettings(); - if (!settings.umamiEnabled) { - return NextResponse.json({}, { status: 204 }); - } - - const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl); - if (targetUrls.length === 0 || !settings.umamiWebsiteId) { - return NextResponse.json({}, { status: 204 }); - } - - const body = await request.arrayBuffer(); - // Limit proxy body size to 64 KB to prevent abuse - if (body.byteLength > 65536) { - return NextResponse.json({}, { status: 413 }); - } - - let lastResponseStatus = 404; - - for (const targetUrl of targetUrls) { - const upstream = await safeFetch( - targetUrl, - { - method: "POST", - headers: { - "content-type": - request.headers.get("content-type") || "application/json", - "user-agent": request.headers.get("user-agent") || "healthlog-proxy", - }, - body, - cache: "no-store", - }, - // Operator-configured Umami host — pin the connect-time IP. - { requirePublicHost: true }, - ); - - lastResponseStatus = upstream.status; - - // Try next candidate only for path misses. - if (upstream.status === 404) { - continue; - } - - return new NextResponse(null, { status: upstream.status }); - } - - return new NextResponse(null, { status: lastResponseStatus }); -}); 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/app/medications/page-client.tsx b/src/app/medications/page-client.tsx index 7d13852ec..b37d4ef51 100644 --- a/src/app/medications/page-client.tsx +++ b/src/app/medications/page-client.tsx @@ -55,6 +55,7 @@ import { Wrench, } from "lucide-react"; import { apiGet } from "@/lib/api/api-fetch"; +import { refetchMedicationsOnMount } from "@/lib/queries/prefetch-medications"; import { useMedicationComplianceSummaryAll } from "@/lib/queries/use-medication-compliance-summary"; import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout"; import { applyMedicationOrder } from "@/lib/medications/medication-order"; @@ -345,7 +346,9 @@ export default function MedicationsPageClient() { // client cache can't know to invalidate a cross-device write. "always" // makes a return-visit fetch fresh; the per-user server cache keeps it // cheap. Mutation-driven invalidation still covers same-tab writes. - refetchOnMount: "always", + // A prefetch this same navigation fired (nav-link intent, route commit) + // already is that fresh fetch, so it is not sent twice. + refetchOnMount: refetchMedicationsOnMount("list"), }); // v1.30.1 M12 — pull-to-refresh parity with labs/checkups/mood/measurements. diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index 201e4e239..a240df37f 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -39,7 +39,9 @@ import MedicationsPageClient from "./page-client"; * event, so the list must re-verify on every mount. With the prefetch in * place the hydrated cards paint immediately and that refetch runs in the * background — the "empty then fills" flash is gone WITHOUT dropping the - * freshness guarantee. + * freshness guarantee. The one exception is data a prefetch fired by this + * same navigation just fetched (`refetchMedicationsOnMount`): that already + * is the re-verification, so it is not sent twice. * - Record identity: the session comes from `getUnswitchedSession()`, which * answers null while the browser is acting on somebody else's record. * `/api/medications` IS delegable, so the client cell corrects itself on its diff --git a/src/components/charts/chart-runtime.ts b/src/components/charts/chart-runtime.ts index 56dd4ab60..93a51dcfc 100644 --- a/src/components/charts/chart-runtime.ts +++ b/src/components/charts/chart-runtime.ts @@ -23,8 +23,17 @@ * "recharts" directly. * - Types stay importable from the component files directly (type-only * imports are value-free and don't drag the chunk in). + * + * Loading this module also moves Recharts' text-measurement span out of + * `` before the first chart measures anything; see + * `text-measurement-host.ts` for why that read was the dashboard's largest + * main-thread cost. */ +import { installTextMeasurementHost } from "./text-measurement-host"; + +installTextMeasurementHost(); + export { HealthChart } from "./health-chart"; export { MoodChart } from "./mood-chart"; export { MedicationComplianceChart } from "./medication-compliance-chart"; diff --git a/src/components/charts/text-measurement-host.ts b/src/components/charts/text-measurement-host.ts new file mode 100644 index 000000000..f27bc2332 --- /dev/null +++ b/src/components/charts/text-measurement-host.ts @@ -0,0 +1,73 @@ +/** + * Where Recharts measures text, moved out of ``. + * + * Recharts sizes every axis tick and label by writing the string into one + * shared, off-screen `` and reading its + * `getBoundingClientRect()`. It looks the span up by id and creates it under + * `` only when it is missing, so whichever element carries that id is + * the one it uses. + * + * Under `` each of those reads cost a document-wide style recalc. The + * stylesheet carries `:has()` rules whose subject is any descendant of an + * anchor (`:is(:where(.group/avatar-group):has(…) *)`, the card and dialog + * slot rules), so Chromium cannot scope the invalidation a text change in a + * `` descendant causes. Measured on a seeded dashboard: 149 reads per + * cold load, about 2.8 ms each unthrottled (about 1.8 s of the 2.5 s of + * blocking time under a 4x CPU slowdown). The same read on a span outside + * ``, inside a `contain: strict` box, costs about 0.01 ms. + * + * So the span lives under ``, next to ``, inside a zero-size + * contained host. The host copies ``'s class list, which is where the + * app font is set (`--font-sans` + `font-sans`), so every string measures in + * the same font at the same size as before and the charts lay out exactly as + * they did. + */ + +export const RECHARTS_MEASUREMENT_SPAN_ID = "recharts_measurement_span"; + +const HOST_STYLE = [ + "position:absolute", + "top:0", + "left:0", + "width:0", + "height:0", + "overflow:hidden", + "contain:strict", + "visibility:hidden", + "pointer-events:none", +].join(";"); + +/** + * Put the measurement span into its contained host under ``. Moves a + * span Recharts already created under ``; a no-op once installed. + * Returns the span, or `null` where there is no DOM. + */ +export function installTextMeasurementHost( + doc: Document | undefined = typeof document === "undefined" + ? undefined + : document, +): HTMLElement | null { + if (!doc?.documentElement || !doc.body) return null; + const existing = doc.getElementById(RECHARTS_MEASUREMENT_SPAN_ID); + const parent = existing?.parentElement; + if ( + existing && + parent?.dataset.slot === "chart-text-measurement-host" && + parent.parentElement === doc.documentElement + ) { + return existing; + } + + const host = doc.createElement("div"); + host.dataset.slot = "chart-text-measurement-host"; + host.setAttribute("aria-hidden", "true"); + host.className = doc.body.className; + host.style.cssText = HOST_STYLE; + + const span = existing ?? doc.createElement("span"); + span.id = RECHARTS_MEASUREMENT_SPAN_ID; + span.setAttribute("aria-hidden", "true"); + host.appendChild(span); + doc.documentElement.appendChild(host); + return span; +} 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 ( +