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/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; +}