|
| 1 | +# ADR 0009: Performance Benchmarks via Vitest Browser Mode (base-ui–style harness, Angular-native metrics) |
| 2 | + |
| 3 | +- Status: Proposed |
| 4 | +- Date: 2026-06-12 |
| 5 | +- Decision owners: Radix NG maintainers |
| 6 | +- Related: new project `apps/radix-perf-testing` (consumers: all primitives; pilot: `checkbox`, `select`), `.github/workflows/`, `tools/scripts/benchmark/` |
| 7 | + |
| 8 | +## Context |
| 9 | + |
| 10 | +The library has no performance testing of any kind today — no harness, no bench files, no CI step. |
| 11 | +Regressions in mount cost (DI context creation, `effect()` setup, host bindings) or in large-list |
| 12 | +scenarios (Select/Autocomplete popups with hundreds of options) are only caught by accident. |
| 13 | + |
| 14 | +[Base UI](https://base-ui.com/) — our primary reference — runs per-PR performance tests and posts a |
| 15 | +comparison comment on every PR: |
| 16 | + |
| 17 | +``` |
| 18 | +Performance |
| 19 | +Total duration: 1,277.17 ms -177.08 ms (-12.2%) | Renders: 50 (+0) | Paint: 1,917.67 ms -266.72 ms (-12.2%) |
| 20 | +
|
| 21 | +Test Duration Renders |
| 22 | +Checkbox mount (500 instances) 66.82 ms ▼ -47.27 ms (-41.4%) 1 (+0) |
| 23 | +11 tests within noise — details |
| 24 | +``` |
| 25 | + |
| 26 | +### How base-ui actually does it (verified against source) |
| 27 | + |
| 28 | +- Bench files live in [`test/performance/tests/*.bench.tsx`](https://github.com/mui/base-ui/tree/master/test/performance); |
| 29 | + the whole `vitest.config.ts` is one line: `createBenchmarkVitestConfig()` from |
| 30 | + [`@mui/internal-benchmark`](https://github.com/mui/mui-public/tree/master/packages/benchmark) (lives in `mui/mui-public`). |
| 31 | +- The runner is **Vitest Browser Mode with Playwright (real Chromium)** — not jsdom, not Tachometer, |
| 32 | + and **not** Vitest's built-in `bench()`/Tinybench. |
| 33 | +- Their `benchmark(name, render, interaction?, options?)` helper does 10 warmup runs + 20 measured |
| 34 | + runs (configurable), with IQR-based outlier removal. |
| 35 | +- Metrics: |
| 36 | + - **Duration / Renders** — `React.Profiler` on React's profiling build; |
| 37 | + - **Paint** — the **Element Timing API**: an invisible sentinel element with an `elementtiming` |
| 38 | + attribute observed via `PerformanceObserver`, capturing the time until the browser actually |
| 39 | + paints the frame. This part is framework-agnostic. |
| 40 | +- CI (CircleCI): `BENCHMARK_UPLOAD=true pnpm test:benchmark` uploads result JSON to S3 keyed by SHA. |
| 41 | + Baseline comes either from a **same-job run of the base branch** (`BENCHMARK_BASELINE_PATH` — |
| 42 | + same runner for both sides, which is also their noise mitigation) or as a fallback from S3 by |
| 43 | + merge-base SHA. |
| 44 | +- The PR comment is **compact**; its "details" link points to MUI's |
| 45 | + [code-infra dashboard](https://github.com/mui/mui-public/tree/master/apps/code-infra-dashboard) |
| 46 | + (`code-infra-dashboard.onrender.com/benchmark-details/...?sha=…&base=…&prNumber=…`), which fetches |
| 47 | + `benchmark.json` for both SHAs from their **private S3 bucket** (`mui-org-ci`) and renders the |
| 48 | + full comparison. The dashboard itself is MUI-internal ("highly opinionated", external feature |
| 49 | + requests not accepted) and reads only their bucket — **not reusable by third parties**. |
| 50 | +- The comparison logic, however, is open source — |
| 51 | + [`compareBenchmarkReports.ts`](https://github.com/mui/mui-public/blob/master/apps/code-infra-dashboard/src/lib/benchmark/compareBenchmarkReports.ts): |
| 52 | + "within noise" is a plain relative-difference threshold (`NOISE_THRESHOLD = 0.2`, i.e. ±20%; no |
| 53 | + confidence intervals or t-tests), severity is error/success only beyond it, totals are simple |
| 54 | + sums compared with the same rule, and per-test rows show **mean ± stdDev** plus an outlier count. |
| 55 | + |
| 56 | +### What does and does not transfer |
| 57 | + |
| 58 | +| base-ui piece | Transfers? | Angular-native replacement | |
| 59 | +| ------------------------------------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------- | |
| 60 | +| Vitest Browser Mode + Playwright | ✅ as-is | We already use Vitest + the AnalogJS Angular Vite plugin | |
| 61 | +| `benchmark()` with warmup/runs/IQR | ✅ design as-is | Own ~150-line harness (no React dependency in the algorithm) | |
| 62 | +| `React.Profiler` for Renders | ❌ | Count change-detection cycles by wrapping `ApplicationRef.tick`; optionally Angular's DevTools profiler hook (`ɵsetProfiler`) later | |
| 63 | +| Element Timing sentinel for Paint | ✅ as-is | Same `elementtiming` attribute + `PerformanceObserver`, nothing React-specific | |
| 64 | +| S3 upload (private bucket `mui-org-ci`) | ❌ overkill | Same-runner baseline via `git worktree` at merge-base; result JSONs kept as workflow artifacts | |
| 65 | +| code-infra dashboard (details page) | ❌ MUI-internal | GitHub Actions **job summary** (`$GITHUB_STEP_SUMMARY`) with the full table; the PR comment links to it | |
| 66 | +| `compareBenchmarkReports.ts` logic & table format | ✅ port it | `tools/scripts/benchmark/compare.mjs` ports the diff/severity/totals logic and rendering | |
| 67 | +| CircleCI + bot comment | ❌ | GitHub Actions + sticky PR comment | |
| 68 | + |
| 69 | +## Decision |
| 70 | + |
| 71 | +Mirror base-ui's benchmark design 1:1 on our stack: |
| 72 | + |
| 73 | +1. A dedicated Nx project **`apps/radix-perf-testing`** (naming mirrors `apps/radix-ssr-testing`) |
| 74 | + running **Vitest Browser Mode with the Playwright provider (Chromium)**. It is **not** part of the |
| 75 | + regular `test` target and is excluded from CI's normal test/build runs the same way |
| 76 | + `radix-ssr-testing` is. |
| 77 | +2. An own **`benchmark()` harness** (in `apps/radix-perf-testing/src/harness/`) implementing |
| 78 | + warmup + measured runs + IQR outlier removal, producing a result JSON per run. |
| 79 | +3. **Angular-native metrics**: wall-clock mount duration, change-detection cycle count, and paint |
| 80 | + time via the Element Timing API. |
| 81 | +4. **CI comparison on the same runner**: benchmark the PR head, then the merge-base via |
| 82 | + `git worktree`, diff the two JSONs with a Node script, and post/update a **sticky PR comment** |
| 83 | + with the comparison table and a within-noise verdict per test. |
| 84 | + |
| 85 | +## Alternatives considered |
| 86 | + |
| 87 | +- **[Tachometer](https://github.com/google/tachometer)** — statistically rigorous, real browser, |
| 88 | + built-in two-build comparison. Rejected: requires standalone HTML harness pages per scenario |
| 89 | + (a parallel build pipeline next to Vitest), and its horizon for maintenance is unclear. Vitest |
| 90 | + browser mode gives us the same real-Chromium fidelity inside infra we already run. |
| 91 | +- **Vitest `bench()` (Tinybench) in jsdom** — cheapest to add, but jsdom has no layout/paint, so no |
| 92 | + Paint metric and unrealistic DOM costs; Tinybench also cannot express the renders/paint metrics, |
| 93 | + so we'd outgrow it immediately. Rejected as the primary harness (it was the original proposal |
| 94 | + before base-ui's actual setup was verified). |
| 95 | +- **Manual profiling with Angular DevTools** — not automatable, no PR gate. Not an alternative, |
| 96 | + just the status quo. |
| 97 | + |
| 98 | +## Detailed design |
| 99 | + |
| 100 | +### Directory layout |
| 101 | + |
| 102 | +``` |
| 103 | +apps/radix-perf-testing/ |
| 104 | + project.json # Nx project, target: bench (@nx/vitest:test or run-commands) |
| 105 | + vite.config.mts # browser mode config (see below) |
| 106 | + tsconfig.json |
| 107 | + src/ |
| 108 | + harness/ |
| 109 | + benchmark.ts # benchmark() — warmup, runs, IQR, result collection |
| 110 | + metrics.ts # CD-cycle counter, Element Timing paint observer |
| 111 | + mount.ts # mountN() — createApplication + createComponent helpers |
| 112 | + reporter.ts # writes bench-results.json (BENCH_OUTPUT_PATH env) |
| 113 | + tests/ |
| 114 | + checkbox.bench.ts # pilot: mount 500, toggle interaction |
| 115 | + select.bench.ts # pilot: open popup with 1000 options |
| 116 | +tools/scripts/benchmark/ |
| 117 | + compare.mjs # head.json + base.json → markdown table + noise verdict |
| 118 | +``` |
| 119 | + |
| 120 | +### Vite/Vitest config |
| 121 | + |
| 122 | +```ts |
| 123 | +// apps/radix-perf-testing/vite.config.mts |
| 124 | +import angular from '@analogjs/vite-plugin-angular'; |
| 125 | +import { defineConfig } from 'vite'; |
| 126 | +import tsconfigPaths from 'vite-tsconfig-paths'; |
| 127 | + |
| 128 | +export default defineConfig({ |
| 129 | + plugins: [angular(), tsconfigPaths()], |
| 130 | + test: { |
| 131 | + include: ['src/tests/**/*.bench.ts'], |
| 132 | + browser: { |
| 133 | + enabled: true, |
| 134 | + provider: 'playwright', |
| 135 | + headless: true, |
| 136 | + instances: [{ browser: 'chromium' }] |
| 137 | + }, |
| 138 | + // benchmarks are sequential by nature — one file at a time, no parallelism |
| 139 | + fileParallelism: false |
| 140 | + } |
| 141 | +}); |
| 142 | +``` |
| 143 | + |
| 144 | +Notes: |
| 145 | + |
| 146 | +- **Do not reuse `packages/primitives/test-setup.ts`** — it initializes the jsdom/TestBed |
| 147 | + environment. The perf app bootstraps real applications via `createApplication` instead. |
| 148 | +- `*.bench.ts` files use plain `it()`/`describe()` from Vitest (as base-ui does — their `.bench.tsx` |
| 149 | + files are regular browser-mode tests, the "bench" is the helper, not Vitest bench mode), so the |
| 150 | + `include` pattern above keeps them out of every other project's `**/*.spec.ts` globs. |
| 151 | +- The suite stays **zoneless**: `provideZonelessChangeDetection()` in every bootstrapped app. |
| 152 | + |
| 153 | +### Harness API |
| 154 | + |
| 155 | +```ts |
| 156 | +export interface BenchmarkOptions { |
| 157 | + runs?: number; // default 20 |
| 158 | + warmupRuns?: number; // default 10 |
| 159 | +} |
| 160 | + |
| 161 | +export interface BenchmarkResult { |
| 162 | + name: string; |
| 163 | + duration: Stats; // ms, IQR-filtered: median, mean, stdDev, min, max, samples, outliersRemoved |
| 164 | + renders: number; // CD cycles during the measured section (must be stable across runs) |
| 165 | + paint?: Stats; // ms, time to sentinel paint (Element Timing) |
| 166 | +} |
| 167 | + |
| 168 | +export async function benchmark( |
| 169 | + name: string, |
| 170 | + setup: () => BenchScenario, // fresh component/app per iteration |
| 171 | + interaction?: (ctx: BenchContext) => Promise<void>, // optional re-render scenario |
| 172 | + options?: BenchmarkOptions |
| 173 | +): Promise<void>; |
| 174 | +``` |
| 175 | + |
| 176 | +Per iteration: |
| 177 | + |
| 178 | +1. `setup()` creates a fresh host element + `createApplication({ providers: [provideZonelessChangeDetection()] })`, |
| 179 | + then `createComponent()` × N into it. The harness appends its own sentinel |
| 180 | + `<div elementtiming="bench-sentinel">` after the mounted components (see risk 2 for the |
| 181 | + constraints on that element). |
| 182 | +2. `performance.mark()` before mount; `ApplicationRef.tick()` (wrapped to count CD cycles); await the |
| 183 | + sentinel's `PerformanceObserver` entry (type `element`) for the paint timestamp. |
| 184 | +3. If `interaction` is provided, measure it as a separate named section (mutate signals, await |
| 185 | + stabilization + a fresh sentinel paint). |
| 186 | +4. Destroy the `ApplicationRef`, remove the host element, `performance.clearMarks()`. |
| 187 | + |
| 188 | +After all iterations: drop outliers outside `[Q1 - 1.5·IQR, Q3 + 1.5·IQR]`, compute stats, append to |
| 189 | +the in-memory result list. A Vitest `afterAll`/reporter hook writes the full |
| 190 | +`BenchmarkResult[]` JSON to `process.env.BENCH_OUTPUT_PATH ?? 'bench-results.json'`. |
| 191 | + |
| 192 | +**Renders metric**: wrap `ApplicationRef.tick` (or subscribe to a counter incremented in an |
| 193 | +`afterRender` callback registered on the app injector) and report the count per iteration. It must be |
| 194 | +deterministic; the harness should warn if the count varies across runs. `ɵsetProfiler`-based template |
| 195 | +profiling is explicitly out of scope for v1 (private API; revisit only if CD-cycle counts prove too |
| 196 | +coarse). |
| 197 | + |
| 198 | +### Pilot bench files |
| 199 | + |
| 200 | +- `checkbox.bench.ts`: |
| 201 | + - `Checkbox mount (500 instances)` — direct analog of base-ui's test; |
| 202 | + - `Checkbox toggle (500 instances)` — interaction: flip all `checked` signals, one CD pass expected. |
| 203 | +- `select.bench.ts`: |
| 204 | + - `Select open (1000 options)` — mount closed, interaction opens the popup; this covers collection |
| 205 | + (DOM-order `contentChildren`), popper positioning, and portal cost — our highest-risk path. |
| 206 | + |
| 207 | +Scenario components live next to the bench files, are standalone, and use **no styles** (headless — |
| 208 | +paint cost is dominated by DOM size, which is exactly what we want to track). |
| 209 | + |
| 210 | +### Scripts and targets |
| 211 | + |
| 212 | +- `project.json` target `radix-perf-testing:bench` (vitest run with the config above). |
| 213 | +- Root `package.json`: `"primitives:bench": "nx run radix-perf-testing:bench"` under the |
| 214 | + `----Radix Primitives---` section. |
| 215 | +- Nx: the target must **not** be cacheable (timings are not reproducible artifacts). |
| 216 | + |
| 217 | +### CI: comparison job and PR comment |
| 218 | + |
| 219 | +New workflow `.github/workflows/benchmark.yml`: |
| 220 | + |
| 221 | +- Trigger: `pull_request` with a `paths` filter on `packages/primitives/**` (plus the perf app and |
| 222 | + the workflow itself). Add a `workflow_dispatch` for manual runs. |
| 223 | +- Steps: |
| 224 | + 1. Checkout with `fetch-depth: 0`; `pnpm install`; install Playwright Chromium. |
| 225 | + 2. Bench **head**: `BENCH_OUTPUT_PATH=head.json pnpm primitives:bench`. |
| 226 | + 3. `git worktree add ../base $(git merge-base HEAD origin/main)`; `pnpm install` in the worktree; |
| 227 | + bench **base** there with `BENCH_OUTPUT_PATH=base.json`, **reusing the head worktree's harness |
| 228 | + if the perf app doesn't exist at merge-base yet** (bootstrap period: skip comparison and post a |
| 229 | + "baseline unavailable" comment instead of failing). |
| 230 | + 4. `node tools/scripts/benchmark/compare.mjs head.json base.json` produces **two outputs**: |
| 231 | + a compact `comment.md` and a full per-test breakdown appended to `$GITHUB_STEP_SUMMARY` |
| 232 | + (our substitute for base-ui's dashboard details page). `compare.mjs` **ports the diff / |
| 233 | + severity / totals logic and table format** from base-ui's open-source |
| 234 | + [`compareBenchmarkReports.ts`](https://github.com/mui/mui-public/blob/master/apps/code-infra-dashboard/src/lib/benchmark/compareBenchmarkReports.ts) |
| 235 | + rather than inventing its own. |
| 236 | + 5. Post/update a sticky comment (`marocchino/sticky-pull-request-comment` or |
| 237 | + `actions/github-script` with a hidden marker), formatted like base-ui's: |
| 238 | + totals line, per-test table (duration delta with ▲/▼ and %, renders delta), collapsed |
| 239 | + `<details>` for within-noise tests, and a **"details" link to the workflow run's job summary**. |
| 240 | + 6. Upload `head.json` / `base.json` as **workflow artifacts** (the repo-local substitute for |
| 241 | + base-ui's S3-by-SHA storage; subject to the default artifact retention window). |
| 242 | +- **Noise rule (v1)**: follow base-ui's actual rule — _within noise_ when |
| 243 | + `|relative Δ of the median| ≤ 20%` (`NOISE_THRESHOLD = 0.2`; they apply it to means, we apply it |
| 244 | + to IQR-filtered medians). Optionally tighten later (e.g. require non-overlapping IQR ranges) once |
| 245 | + real runner variance is known — base-ui's generous 20% reflects how noisy CI timings actually are. |
| 246 | +- The job is **informational only** (no required status) until variance on GitHub-hosted runners is |
| 247 | + characterized; same-runner head/base execution is the primary mitigation. |
| 248 | + |
| 249 | +## Consequences |
| 250 | + |
| 251 | +Positive: |
| 252 | + |
| 253 | +- Per-PR regression visibility for mount cost and large-list popup scenarios, with real-browser |
| 254 | + paint numbers — matching the reference project's developer experience. |
| 255 | +- The harness algorithm (warmup/runs/IQR, Element Timing) is copied from a battle-tested design |
| 256 | + instead of invented. |
| 257 | +- No external storage or services; the whole pipeline is repo-local. |
| 258 | + |
| 259 | +Negative / accepted costs: |
| 260 | + |
| 261 | +- GitHub-hosted runners are noisy; even with same-runner comparison, small regressions (<5%) will |
| 262 | + hide in noise. Accepted for v1; thresholds are tunable and the job is non-blocking. |
| 263 | +- The CI job installs dependencies twice (head + merge-base worktree). Accepted: pnpm store makes |
| 264 | + the second install cheap. |
| 265 | +- Renders metric is coarser than React's Profiler (CD cycles, not per-component render timings). |
| 266 | + Accepted; can be deepened later via the profiler hook. |
| 267 | + |
| 268 | +## Risks and open questions |
| 269 | + |
| 270 | +1. **AnalogJS Angular plugin under Vitest Browser Mode** — the main technical risk; it is known to |
| 271 | + work in jsdom and in Storybook's Vite pipeline, but browser mode must be verified first. |
| 272 | + **Mitigation / go-no-go**: phase 1 is a spike that mounts one Checkbox in browser mode before any |
| 273 | + harness work. Fallback if it fails: precompile scenarios with a small Vite build (the Storybook |
| 274 | + builder path) — decided only if needed. |
| 275 | +2. **Element Timing in headless Chromium** — paints do occur in headless mode, but the observer |
| 276 | + wiring must be verified in the spike. The entry type `element` requires the `elementtiming` |
| 277 | + attribute on a rendered, non-`display:none` element — the harness appends a 1×1 transparent |
| 278 | + sentinel element itself (scenario components don't need to know about it), positioned offscreen |
| 279 | + rather than hidden. |
| 280 | +3. **Runner variance** — collect ~2 weeks of runs before considering making the check blocking or |
| 281 | + alerting on it. |
| 282 | + |
| 283 | +## Rollout plan (implementation phases) |
| 284 | + |
| 285 | +1. **Spike (go/no-go)**: `apps/radix-perf-testing` skeleton + browser-mode config + one trivial test |
| 286 | + mounting `RdxCheckboxRootDirective`-based component and observing a sentinel `element` timing |
| 287 | + entry. Verifies risks 1 and 2. |
| 288 | +2. **Harness + pilots**: `benchmark()` with warmup/runs/IQR, CD-cycle counter, JSON reporter; |
| 289 | + `checkbox.bench.ts` and `select.bench.ts`; `primitives:bench` script; local numbers reviewed for |
| 290 | + stability (run 5× locally, compare medians). |
| 291 | +3. **CI**: `benchmark.yml`, `compare.mjs`, sticky comment, noise thresholds; non-blocking. |
| 292 | +4. **Later (separate decisions)**: more primitives (Autocomplete virtualized, Menu, Slider drag), |
| 293 | + profiler-hook render timings, blocking thresholds, historical tracking. |
| 294 | + |
| 295 | +## References |
| 296 | + |
| 297 | +- base-ui bench file: <https://github.com/mui/base-ui/blob/master/test/performance/tests/checkbox.bench.tsx> |
| 298 | +- base-ui perf suite: <https://github.com/mui/base-ui/tree/master/test/performance> |
| 299 | +- `@mui/internal-benchmark`: <https://github.com/mui/mui-public/tree/master/packages/benchmark> |
| 300 | +- base-ui CI job (`test_benchmark`): <https://github.com/mui/base-ui/blob/master/.circleci/config.yml> |
| 301 | +- MUI code-infra dashboard (benchmark details page; MUI-internal, reads their private S3): <https://github.com/mui/mui-public/tree/master/apps/code-infra-dashboard> |
| 302 | +- Comparison/noise logic to port into `compare.mjs`: <https://github.com/mui/mui-public/blob/master/apps/code-infra-dashboard/src/lib/benchmark/compareBenchmarkReports.ts> |
| 303 | +- Element Timing API: <https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming> |
| 304 | +- Vitest Browser Mode: <https://vitest.dev/guide/browser/> |
0 commit comments