perf(charts): measure chart text outside <body>

Recharts sizes every tick and label by writing it into one off-screen
span and reading getBoundingClientRect(). Under <body> each read forced
a document-wide style recalc, because the stylesheet's descendant-subject
:has() rules leave Chromium no way to scope the invalidation. A seeded
dashboard made 149 such reads per cold load at about 2.8 ms each, about
1.8 s of main-thread time under a 4x CPU slowdown.

The chart runtime now parks that span in a zero-size, contain: strict
host under <html>, which Recharts finds by id and keeps using. Each read
drops to about 0.01 ms; the dashboard's total blocking time under the
same slowdown falls from about 2.5 s to about 0.75 s. The host copies
<body>'s classes, where the app font is set, so every string measures
exactly as before; an e2e spec checks that parity on the drawn labels
and that the host survives a client-side round trip.
This commit is contained in:
Marc-André Bombeck committed 2026-10-04 15:28:28 +02:00
1 parent 1b1da652d3
commit 2c1369f01b
3 files changed
+186

No files matched your search

+104
View File
@@ -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 `<body>` 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
* `<html>` (`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 `<body>`, including after a client-side round trip;
* - every string the charts drew measures exactly as it does under
* `<body>`, 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 <body>, 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 <body>.
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);
});
});
+9
View File
@@ -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
* `<body>` 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";
@@ -0,0 +1,73 @@
/**
* Where Recharts measures text, moved out of `<body>`.
*
* Recharts sizes every axis tick and label by writing the string into one
* shared, off-screen `<span id="recharts_measurement_span">` and reading its
* `getBoundingClientRect()`. It looks the span up by id and creates it under
* `<body>` only when it is missing, so whichever element carries that id is
* the one it uses.
*
* Under `<body>` 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
* `<body>` 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
* `<body>`, inside a `contain: strict` box, costs about 0.01 ms.
*
* So the span lives under `<html>`, next to `<body>`, inside a zero-size
* contained host. The host copies `<body>`'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 `<html>`. Moves a
* span Recharts already created under `<body>`; 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;
}