Files
homarr/scripts/benchmarks/verify-context-menu.mts
T
Thomas Camlong 933a54ead9 docs(perf): consolidated before/after, plus what is still unverified
All previous numbers were spread across rounds with different instruments and
different workloads. This is one instrument, one workload, alternated, 2 runs per
side, merge-base vs HEAD:

  server idle        141.9 -> 89.7 MiB   (-37%)
  server loaded      257.3 -> 173.0 MiB  (-33%)
  server peak        705.2 -> 568.4 MiB  (-19%)
  after idle soak    332.0 -> 232.5 MiB  (-30%)
  JS event listeners 10,126 -> 7,774     (-23%)
  React fibers       5,224 -> 3,588      (-31%)
  DOM nodes          9,175 -> 8,284      (-10%)
  browser JS heap    55.2 -> 51.1 MiB    (-7%)
  image              410 -> 398 MB       (-3%)
  container CPU      22.8 -> 22.8 s      unchanged

Recorded prominently rather than buried: under *sustained* 4-tab load the two builds
converge, 368 vs 364 MiB. The wins are idle, steady state and peak. That is why
hammering the app with five tabs did not feel 30% lighter — at saturation the
footprint is live widget data and render churn, which this PR does not change.

Also documents the gaps, since "tested" without them would be misleading: no
multi-hour soak (the #5301 reports are about days), single user, SQLite only, the
DevTools Profiler tab confirmed at bundle level rather than in the extension, and
the App Router re-rendering ~1x/second still unfixed.

verify-context-menu.mts now also covers touch long-press, and no longer asserts on a
fiber total — that counter swung 7,454 to 16,149 between runs of the same build, so
it was noise. probe-fiber-tree.mts is the stable counter and is what the -31% above
comes from.
2026-08-09 15:39:41 +02:00

180 lines
7.5 KiB
TypeScript

/**
* Functional gate for the shared board context menu.
*
* A fiber count alone is not enough: an earlier attempt at this optimisation cut the
* idle tree *and* broke right-click, which looked like a win until this ran. So the
* saving is only reported if the menu still opens, shows real actions, closes, and
* works on a second, different widget.
*
* STRESS_BASE_URL=http://127.0.0.1:PORT \
* node --experimental-strip-types scripts/benchmarks/verify-context-menu.mts
*/
import { chromium } from "@playwright/test";
import type { Page } from "@playwright/test";
const baseUrl = process.env.STRESS_BASE_URL;
if (!baseUrl) throw new Error("STRESS_BASE_URL is required");
const username = process.env.STRESS_USERNAME ?? "demo";
const password = process.env.STRESS_PASSWORD ?? "demodemo";
const boardName = process.env.STRESS_BOARD ?? "default";
const failures: string[] = [];
const check = (ok: boolean, description: string) => {
console.log(` ${ok ? "PASS" : "FAIL"} ${description}`);
if (!ok) failures.push(description);
};
const browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
await context.addInitScript(() => {
const roots = new Set();
(globalThis as unknown as Record<string, unknown>).__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
renderers: new Map(),
supportsFiber: true,
inject: () => 1,
onCommitFiberRoot: (_id: unknown, root: unknown) => roots.add(root),
onCommitFiberUnmount: () => undefined,
onPostCommitFiberRoot: () => undefined,
__homarrRoots: roots,
};
});
const page = await context.newPage();
const countFibers = (target: Page) =>
target.evaluate(() => {
const hook = (globalThis as unknown as Record<string, { __homarrRoots?: Set<{ current: unknown }> }>)
.__REACT_DEVTOOLS_GLOBAL_HOOK__;
const roots = [...(hook?.__homarrRoots ?? [])].map((root) => root.current);
const seen = new Set();
const walk = (fiber: { child?: unknown; sibling?: unknown } | null, depth: number) => {
if (!fiber || seen.has(fiber) || depth > 3000) return;
seen.add(fiber);
walk(fiber.child as never, depth + 1);
walk(fiber.sibling as never, depth);
};
for (const root of roots) walk(root as never, 0);
return seen.size;
});
const dropdown = page.locator('[role="menu"]:visible, .mantine-Menu-dropdown:visible').first();
/**
* Playwright's `click({ button: "right" })` does not produce a `contextmenu` event in
* this setup, so it reports every build as broken — including shipped code. Dispatch
* the event the app actually listens for instead.
*/
const rightClickItem = (index: number) =>
page.evaluate((itemIndex) => {
const items = [...document.querySelectorAll('[data-homarr-dev-benchmark-board] [data-type="item"]')];
const item = items[itemIndex];
if (!item) return false;
const rect = item.getBoundingClientRect();
const target = (item.querySelector("*") as HTMLElement | null) ?? (item as HTMLElement);
target.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: rect.x + rect.width / 2,
clientY: rect.y + rect.height / 2,
button: 2,
}),
);
return true;
}, index);
await page.goto(`${baseUrl}/auth/login`, { waitUntil: "networkidle" });
await page.getByLabel("Username").fill(username);
await page.locator("#password").fill(password);
await page.locator("css=button[type='submit']").click();
await page.waitForFunction(() => !window.location.pathname.includes("/auth/login"), undefined, { timeout: 60_000 });
await page.goto(`${baseUrl}/boards/${boardName}`, { waitUntil: "domcontentloaded" });
await page.locator("[data-homarr-dev-benchmark-board]").waitFor({ state: "visible", timeout: 120_000 });
await page
.waitForFunction(
() => {
const items = [...document.querySelectorAll('[data-type="item"]')];
return (
items.length > 0 &&
items.every((item) => item.querySelector("[data-homarr-widget-ready],[data-homarr-widget-error]"))
);
},
undefined,
{ timeout: 120_000 },
)
.catch(() => console.log(" warn: widgets did not all settle"));
const items = page.locator('[data-homarr-dev-benchmark-board] [data-type="item"]');
const itemCount = await items.count();
console.log(`board items: ${itemCount}`);
const idleFibers = await countFibers(page);
console.log(`fibers with board idle: ${idleFibers}\n`);
console.log("right-click behaviour:");
check(!(await dropdown.isVisible().catch(() => false)), "no menu is open before interacting");
await rightClickItem(0);
const openedFirst = await dropdown
.waitFor({ state: "visible", timeout: 8_000 })
.then(() => true)
.catch(() => false);
check(openedFirst, "right-click opens the context menu");
const actionCount = await page.locator('[role="menu"]:visible [role="menuitem"], .mantine-Menu-item:visible').count();
check(actionCount > 0, `menu contains actions (found ${actionCount})`);
await page.keyboard.press("Escape");
const closed = await dropdown
.waitFor({ state: "hidden", timeout: 5_000 })
.then(() => true)
.catch(() => false);
check(closed, "Escape closes the menu");
// Touch long-press: the shared menu replaced Mantine's Menu.ContextMenu, which
// handled this natively, so it has to be re-proven rather than assumed.
const longPressItem = (index: number) =>
page.evaluate((itemIndex) => {
const items = [...document.querySelectorAll('[data-homarr-dev-benchmark-board] [data-type="item"]')];
const item = items[itemIndex];
if (!item) return false;
const rect = item.getBoundingClientRect();
const target = (item.querySelector("*") as HTMLElement | null) ?? (item as HTMLElement);
const x = rect.x + rect.width / 2;
const y = rect.y + rect.height / 2;
const touch = new Touch({ identifier: 1, target, clientX: x, clientY: y });
target.dispatchEvent(new TouchEvent("touchstart", { bubbles: true, cancelable: true, touches: [touch] }));
return true;
}, index);
await longPressItem(2 % Math.max(itemCount, 1));
// Delay is 500ms in the component; allow margin.
await page.waitForTimeout(1_200);
const openedByTouch = await dropdown.isVisible().catch(() => false);
check(openedByTouch, "touch long-press opens the context menu");
await page.keyboard.press("Escape").catch(() => undefined);
await dropdown.waitFor({ state: "hidden", timeout: 5_000 }).catch(() => undefined);
// A shared menu has to re-target; a per-item menu would trivially pass the first case.
if (itemCount > 1) {
await rightClickItem(1);
const openedSecond = await dropdown
.waitFor({ state: "visible", timeout: 8_000 })
.then(() => true)
.catch(() => false);
check(openedSecond, "right-click on a second widget re-opens the menu");
await page.keyboard.press("Escape").catch(() => undefined);
await dropdown.waitFor({ state: "hidden", timeout: 5_000 }).catch(() => undefined);
}
// Fiber totals are reported for context only, never asserted on: this counter walks
// every root the devtools hook has seen, and portals opened by the menu make it swing
// by 2x between runs of the same build. Use probe-fiber-tree.mts for a stable count.
const afterFibers = await countFibers(page);
console.log(`\nfibers (context only, not asserted): idle ${idleFibers} -> ${afterFibers} after using the menu`);
console.log(`\n${failures.length === 0 ? "ALL CHECKS PASSED" : `${failures.length} CHECK(S) FAILED`}`);
console.log(JSON.stringify({ itemCount, idleFibers, afterFibers, failures }, null, 2));
await browser.close();
if (failures.length > 0) process.exitCode = 1;