Skip to content

Commit 2d5ab9c

Browse files
committed
feat(bench): add performance benchmarking suite for Bits UI components
1 parent 263d098 commit 2d5ab9c

252 files changed

Lines changed: 2718 additions & 312 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bench/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
dist
2+
node_modules
3+
results/

bench/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Bits UI Benchmarks
2+
3+
Performance benchmarks for the floating/popup components. Chromium via Playwright, CDP CPU throttling, open scenarios measured activation-to-visible-paint, mount scenarios measured as scripted render + layout.
4+
5+
The bench app compiles `bits-ui` **directly from `packages/bits-ui/src`** (see
6+
`vite.config.ts` aliases), so library edits are picked up by a plain rebuild —
7+
no `svelte-package` step needed.
8+
9+
## Scenarios
10+
11+
| scenario | what it measures | CPU throttle |
12+
| ---------------------- | ------------------------------------------------------------- | ------------ |
13+
| `dialog-open` | trigger click → dialog visible, 10k outside DOM nodes | 20x |
14+
| `popover-open` | trigger click → popover visible, 50-field form content | 6x |
15+
| `select-open` | Enter on trigger → listbox visible, 1,000 items | 6x |
16+
| `menu-open` | Enter on trigger → menu visible, 1,000 items | 6x |
17+
| `combobox-open` | ArrowDown on input → listbox visible, 1,000 items | 6x |
18+
| `tooltip-mount` | mount 1,000 `Tooltip.Root` + `Trigger` (avg of 20 iters) | 1x |
19+
| `select-trigger-mount` | mount 1,000 `Select.Root` + `Trigger`, 10 items each | 1x |
20+
| `menu-highlight` | pointermove sweep over open menu items, per-item flush+layout | 1x |
21+
22+
Open scenarios: 2 warmup runs + 5 samples, median reported. Each sample is a
23+
full open/close cycle; only activation → first painted frame with visible
24+
content is timed.
25+
26+
## Usage
27+
28+
```bash
29+
# full suite (builds first)
30+
node runner.mjs --label my-run
31+
32+
# subset, compare against a saved baseline
33+
node runner.mjs --scenarios select-open,menu-open --compare results/baseline.json
34+
35+
# skip the vite build (reuse dist/)
36+
node runner.mjs --skip-build
37+
38+
# CPU-profile one scenario (unminified build, prints hottest functions)
39+
node profile.mjs select-open --runs 3
40+
41+
# trace main-thread breakdown (style recalc / layout / paint) for one scenario
42+
node trace.mjs dialog-open
43+
```
44+
45+
Results are written to `results/<label>.json`.
46+
47+
## Caveats
48+
49+
- Absolute numbers drift with machine state (thermals, background load) —
50+
**always compare A/B in the same session**, e.g. stash/unstash library
51+
changes and run back-to-back. Numbers are not comparable across machines.
52+
- `menu-highlight` measures dispatch → reactivity flush → forced style/layout
53+
per item (not frame-to-frame, which would be vsync-bound).

bench/index.html

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Bits UI Bench</title>
7+
<style>
8+
* {
9+
box-sizing: border-box;
10+
}
11+
body {
12+
margin: 0;
13+
font-family: system-ui, sans-serif;
14+
font-size: 14px;
15+
}
16+
.popup {
17+
background: #fff;
18+
border: 1px solid #ccc;
19+
border-radius: 6px;
20+
box-shadow: 0 4px 16px rgb(0 0 0 / 0.1);
21+
}
22+
.listbox {
23+
max-height: 400px;
24+
overflow-y: auto;
25+
width: 240px;
26+
}
27+
.item {
28+
padding: 4px 8px;
29+
border-radius: 4px;
30+
outline: none;
31+
}
32+
.item[data-highlighted] {
33+
background: #2563eb;
34+
color: #fff;
35+
}
36+
.item[data-state="checked"] {
37+
font-weight: 600;
38+
}
39+
.trigger {
40+
padding: 6px 12px;
41+
border: 1px solid #ccc;
42+
border-radius: 6px;
43+
background: #f9f9f9;
44+
}
45+
.overlay {
46+
position: fixed;
47+
inset: 0;
48+
background: rgb(0 0 0 / 0.4);
49+
}
50+
.dialog {
51+
position: fixed;
52+
left: 50%;
53+
top: 50%;
54+
transform: translate(-50%, -50%);
55+
width: 400px;
56+
padding: 16px;
57+
}
58+
.outside-node {
59+
display: inline-block;
60+
width: 8px;
61+
height: 8px;
62+
margin: 1px;
63+
background: #e5e5e5;
64+
}
65+
.trigger-grid {
66+
display: flex;
67+
flex-wrap: wrap;
68+
gap: 4px;
69+
}
70+
</style>
71+
</head>
72+
<body>
73+
<div id="app"></div>
74+
<script type="module" src="/src/main.ts"></script>
75+
</body>
76+
</html>

bench/package.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "@bits-ui/bench",
3+
"description": "Performance benchmarks for Bits UI floating components.",
4+
"version": "0.0.0",
5+
"private": true,
6+
"type": "module",
7+
"scripts": {
8+
"bench": "node runner.mjs"
9+
},
10+
"dependencies": {
11+
"bits-ui": "workspace:*"
12+
},
13+
"devDependencies": {
14+
"@sveltejs/vite-plugin-svelte": "catalog:",
15+
"playwright": "catalog:",
16+
"svelte": "catalog:",
17+
"typescript": "catalog:",
18+
"vite": "catalog:"
19+
},
20+
"sideEffects": false
21+
}

bench/profile.mjs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* CPU-profiles a single benchmark scenario and prints the hottest functions
3+
* by self time. Builds unminified so function names survive.
4+
*
5+
* Usage: node profile.mjs <scenario> [--runs 3] [--skip-build] [--top 40]
6+
*/
7+
import path from "node:path";
8+
import process from "node:process";
9+
import { fileURLToPath } from "node:url";
10+
import { build, preview } from "vite";
11+
import { chromium } from "playwright";
12+
13+
const dirname = path.dirname(fileURLToPath(import.meta.url));
14+
const PORT = 4518;
15+
16+
const args = process.argv.slice(2);
17+
const scenario = args.find((a) => !a.startsWith("--"));
18+
if (!scenario) {
19+
console.error("usage: node profile.mjs <scenario>");
20+
process.exit(1);
21+
}
22+
function argValue(flag, fallback) {
23+
const i = args.indexOf(flag);
24+
return i === -1 ? fallback : args[i + 1];
25+
}
26+
const runs = Number(argValue("--runs", "3"));
27+
const top = Number(argValue("--top", "40"));
28+
const skipBuild = args.includes("--skip-build");
29+
30+
async function main() {
31+
if (!skipBuild) {
32+
console.log("building (unminified)...");
33+
await build({
34+
root: dirname,
35+
logLevel: "warn",
36+
build: { minify: false, sourcemap: false },
37+
});
38+
}
39+
const server = await preview({
40+
root: dirname,
41+
preview: { port: PORT, strictPort: true },
42+
});
43+
const browser = await chromium.launch({ headless: true });
44+
const page = await browser.newPage();
45+
try {
46+
const cdp = await page.context().newCDPSession(page);
47+
await page.goto(`http://localhost:${PORT}/?scenario=${scenario}`, {
48+
waitUntil: "load",
49+
});
50+
await page.waitForFunction(() => window.__ready || window.__error, null, {
51+
timeout: 60_000,
52+
});
53+
const setupError = await page.evaluate(() => window.__error);
54+
if (setupError) throw new Error(`setup failed: ${setupError}`);
55+
56+
const cpuThrottle = await page.evaluate(() => window.__scenario.cpuThrottle);
57+
await cdp.send("Emulation.setCPUThrottlingRate", { rate: cpuThrottle });
58+
// warmup
59+
await page.evaluate(() => window.__scenario.run());
60+
61+
await cdp.send("Profiler.enable");
62+
await cdp.send("Profiler.setSamplingInterval", { interval: 100 });
63+
await cdp.send("Profiler.start");
64+
const durations = [];
65+
for (let i = 0; i < runs; i++) {
66+
durations.push(await page.evaluate(() => window.__scenario.run()));
67+
}
68+
const { profile } = await cdp.send("Profiler.stop");
69+
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 });
70+
71+
// aggregate self time per function
72+
const totalHits = profile.nodes.reduce((a, n) => a + (n.hitCount ?? 0), 0);
73+
const totalMs = (profile.endTime - profile.startTime) / 1000;
74+
const byFn = new Map();
75+
for (const node of profile.nodes) {
76+
const hits = node.hitCount ?? 0;
77+
if (!hits) continue;
78+
const cf = node.callFrame;
79+
const url = cf.url.replace(/^https?:\/\/[^/]+/, "").split("?")[0];
80+
const key = `${cf.functionName || "(anonymous)"} ${url}:${cf.lineNumber + 1}`;
81+
byFn.set(key, (byFn.get(key) ?? 0) + hits);
82+
}
83+
const rows = [...byFn.entries()]
84+
.map(([key, hits]) => ({ key, hits, ms: (hits / totalHits) * totalMs }))
85+
.sort((a, b) => b.hits - a.hits)
86+
.slice(0, top);
87+
88+
console.log(
89+
`\nscenario=${scenario} cpu=${cpuThrottle}x runs=${runs} run-durations=[${durations.map((d) => d.toFixed(1)).join(", ")}]ms`
90+
);
91+
console.log(`profile wall time ${totalMs.toFixed(0)}ms, ${totalHits} samples\n`);
92+
console.log(`${"self-ms".padEnd(10)}${"self-%".padEnd(9)}function`);
93+
for (const r of rows) {
94+
const pct = ((r.hits / totalHits) * 100).toFixed(1);
95+
console.log(`${r.ms.toFixed(1).padEnd(10)}${`${pct}%`.padEnd(9)}${r.key}`);
96+
}
97+
} finally {
98+
await browser.close();
99+
await new Promise((resolve) => server.httpServer.close(resolve));
100+
}
101+
}
102+
103+
main().catch((e) => {
104+
console.error(e);
105+
process.exit(1);
106+
});

0 commit comments

Comments
 (0)