mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
1b1da652d3
commit
2c1369f01b
3 files changed
+186
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user