@mui/internal-benchmark 0.0.3-canary.7 → 0.0.3-canary.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,7 +3,6 @@
3
3
  ## 2.0.8
4
4
 
5
5
  Test release
6
- dummy PR
7
6
 
8
7
  ## 2.0.7
9
8
 
package/README.md CHANGED
@@ -208,6 +208,38 @@ benchmark('name', renderFn, interaction, {
208
208
  vitest run
209
209
  ```
210
210
 
211
+ ### Profiling in DevTools
212
+
213
+ To profile a benchmark case by hand with the browser DevTools instead of running the automated measurement loop, enable profile mode. It opens a **headed** Chromium window with DevTools already open, and replaces the measurement loop with an interactive control panel:
214
+
215
+ ```bash
216
+ BENCHMARK_PROFILE=true vitest run -t "MyComponent mount"
217
+ ```
218
+
219
+ Each `benchmark()` case renders a toolbar pinned to the top of the page with **Render**, **Finish**, and (when the case has an interaction) **Run interaction** buttons. The **Render** button toggles between mounting and unmounting (it reads **Unmount** while the component is mounted). The component under test stays unmounted until you click **Render**, so the flow is:
220
+
221
+ 1. Switch to the DevTools **Performance** tab and start recording.
222
+ 2. Click **Render** — this mounts the component (the thing you're profiling).
223
+ 3. Stop the recording and inspect. Toggle **Unmount** / **Render** to capture more frames, or **Run interaction** to profile a re-render.
224
+ 4. Click **Finish** to end the case and move to the next one.
225
+
226
+ Filter to a single case with Vitest's `-t "<name>"` (or by file) so the window isn't shared across many cases. Profiling shares the same minimal launch args as measurement (V8 optimization and the GPU stay on for both), so the profiler reflects realistic performance; it differs only by running headed — DevTools open, in a full desktop viewport (below) — so its absolute numbers aren't directly comparable to a measurement run.
227
+
228
+ Both modes render at a 1920x1080 viewport by default (instead of Vitest's phone-sized 414x896). Set `viewport` (or the `BENCHMARK_VIEWPORT` env var) to change it; in profile mode the headed browser window is also sized to match so the full render is visible:
229
+
230
+ ```bash
231
+ BENCHMARK_PROFILE=true BENCHMARK_VIEWPORT=2560x1440 vitest run -t "MyComponent mount"
232
+ ```
233
+
234
+ Profile mode is also settable via the `profile` config option:
235
+
236
+ ```ts
237
+ export default createBenchmarkVitestConfig({
238
+ profile: true,
239
+ viewport: { width: 2560, height: 1440 },
240
+ });
241
+ ```
242
+
211
243
  ### Configuration
212
244
 
213
245
  `createBenchmarkVitestConfig` accepts:
@@ -215,6 +247,8 @@ vitest run
215
247
  - `outputPath` — path for JSON results (default: `benchmarks/results.json`). Also settable via `BENCHMARK_OUTPUT_PATH`.
216
248
  - `baselinePath` — path to a prior results JSON file to inline as the comparison base (see [Baseline comparisons](#baseline-comparisons)). Also settable via `BENCHMARK_BASELINE_PATH`.
217
249
  - `launchArgs` — additional browser launch arguments
250
+ - `profile` — run an interactive profiling session in a headed browser with DevTools instead of measuring (see [Profiling in DevTools](#profiling-in-devtools)). Also settable via `BENCHMARK_PROFILE=true`.
251
+ - `viewport` — `{ width, height }` browser viewport (and window size in profile mode), applied to both modes. Defaults to `1920x1080`. Also settable via `BENCHMARK_VIEWPORT` (e.g. `2560x1440`).
218
252
 
219
253
  To override standard Vitest options (e.g. `include`, `testTimeout`, `headless`), use `mergeConfig`:
220
254
 
package/index.mjs CHANGED
@@ -14,6 +14,7 @@ import { ElementTiming } from "./ElementTiming.mjs";
14
14
  import { ScalarMetric } from "./ScalarMetric.mjs";
15
15
  import { metricsGate } from "./metricsGate.mjs";
16
16
  import { createReactRecordingControls } from "./reactRecording.mjs";
17
+ import { runProfileSession } from "./profileSession.mjs";
17
18
  // Import for TaskMeta augmentation side effect
18
19
  import "./taskMetaAugmentation.mjs";
19
20
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
@@ -39,12 +40,10 @@ function BenchProfiler({
39
40
  recording.markRendered();
40
41
  }
41
42
  }, [captures, recording]);
42
- return /*#__PURE__*/_jsxs(React.Profiler, {
43
+ return /*#__PURE__*/_jsx(React.Profiler, {
43
44
  id: "bench",
44
45
  onRender: onRender,
45
- children: [children, _ElementTiming || (_ElementTiming = /*#__PURE__*/_jsx(ElementTiming, {
46
- name: "default"
47
- }))]
46
+ children: children
48
47
  });
49
48
  }
50
49
 
@@ -66,9 +65,137 @@ function settle() {
66
65
  function supportsElementTiming() {
67
66
  return PerformanceObserver.supportedEntryTypes.includes('element');
68
67
  }
68
+ // Sets up a PerformanceObserver for the Element Timing API and exposes a promise-based
69
+ // `waitForElementTiming` helper. Used by the measurement loop (which also reads `elementEntries`
70
+ // to record paint metrics) and the interactive profiling session.
71
+ function createElementTimingWaiter() {
72
+ const hasElementTiming = supportsElementTiming();
73
+ const elementEntries = [];
74
+ const elementResolvers = new Map();
75
+ let observer = null;
76
+ if (hasElementTiming) {
77
+ observer = new PerformanceObserver(list => {
78
+ for (const entry of list.getEntries()) {
79
+ elementEntries.push(entry);
80
+ const resolver = elementResolvers.get(entry.identifier);
81
+ if (resolver) {
82
+ elementResolvers.delete(entry.identifier);
83
+ resolver();
84
+ }
85
+ }
86
+ });
87
+ observer.observe({
88
+ type: 'element',
89
+ buffered: false
90
+ });
91
+ }
92
+ const waitForElementTiming = (identifier, timeout) => {
93
+ if (!hasElementTiming) {
94
+ console.warn(`waitForElementTiming("${identifier}"): Element Timing API is not supported. ` + 'Paint metrics will not be collected.');
95
+ return Promise.resolve();
96
+ }
97
+ if (elementEntries.some(entry => entry.identifier === identifier)) {
98
+ return Promise.resolve();
99
+ }
100
+ const {
101
+ promise,
102
+ resolve,
103
+ reject
104
+ } = Promise.withResolvers();
105
+ const timeoutMs = timeout ?? 5000;
106
+ const timer = timeoutMs > 0 && timeoutMs < Infinity ? setTimeout(() => {
107
+ elementResolvers.delete(identifier);
108
+ reject(new Error(`waitForElementTiming("${identifier}"): timed out after ${timeoutMs}ms. ` + 'Ensure the element has an `elementtiming` attribute and is visible in the viewport.'));
109
+ }, timeoutMs) : undefined;
110
+ elementResolvers.set(identifier, () => {
111
+ if (timer) {
112
+ clearTimeout(timer);
113
+ }
114
+ resolve();
115
+ });
116
+ return promise;
117
+ };
118
+ return {
119
+ elementEntries,
120
+ waitForElementTiming,
121
+ disconnect: () => observer?.disconnect()
122
+ };
123
+ }
124
+
125
+ // When true, `benchmark()` opens an interactive profiling session in a headed
126
+ // browser instead of running the automated measurement loop. Enabled by
127
+ // `createBenchmarkVitestConfig({ profile: true })` or `BENCHMARK_PROFILE=true`,
128
+ // both of which replace this expression at build time via Vite `define`.
129
+ const PROFILE_MODE = process.env.BENCHMARK_PROFILE === 'true';
130
+ function createCaseRuntime({
131
+ renderFn,
132
+ interaction,
133
+ context,
134
+ onUncaughtError
135
+ }) {
136
+ let root = null;
137
+ let container = null;
138
+ return {
139
+ mount() {
140
+ if (root) {
141
+ return;
142
+ }
143
+ container = document.createElement('div');
144
+ document.body.appendChild(container);
145
+ const newRoot = ReactDOMClient.createRoot(container, {
146
+ onUncaughtError
147
+ });
148
+ root = newRoot;
149
+ ReactDOM.flushSync(() => {
150
+ newRoot.render(/*#__PURE__*/_jsxs(React.Fragment, {
151
+ children: [renderFn(), _ElementTiming || (_ElementTiming = /*#__PURE__*/_jsx(ElementTiming, {
152
+ name: "default"
153
+ }))]
154
+ }));
155
+ });
156
+ },
157
+ interact: interaction ? async () => {
158
+ await interaction(context);
159
+ } : undefined,
160
+ unmount() {
161
+ if (!root) {
162
+ return;
163
+ }
164
+ root.unmount();
165
+ container?.remove();
166
+ root = null;
167
+ container = null;
168
+ },
169
+ isMounted: () => root !== null
170
+ };
171
+ }
69
172
  export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
70
173
  const interaction = typeof interactionOrOptions === 'function' ? interactionOrOptions : undefined;
71
174
  const options = typeof interactionOrOptions === 'object' ? interactionOrOptions : maybeOptions;
175
+
176
+ // In profile mode, skip the automated measurement loop entirely: build a bare case runtime (no
177
+ // BenchProfiler wrapper, no-op recording since the user drives DevTools by hand) and hand it to
178
+ // the interactive panel.
179
+ if (PROFILE_MODE) {
180
+ it(name, async () => {
181
+ const timing = createElementTimingWaiter();
182
+ // No `wrap`: profile mode renders the component bare (no BenchProfiler / React Profiler
183
+ // overhead in the hand-captured trace). The runtime still plants the `default` sentinel, so
184
+ // the first paint shows up as a labeled marker in the DevTools Performance timeline.
185
+ const runtime = createCaseRuntime({
186
+ renderFn,
187
+ interaction,
188
+ context: {
189
+ waitForElementTiming: timing.waitForElementTiming,
190
+ pauseReactRecording: () => {},
191
+ resumeReactRecording: () => {}
192
+ }
193
+ });
194
+ await runProfileSession(name, runtime);
195
+ timing.disconnect();
196
+ });
197
+ return;
198
+ }
72
199
  it(name, async ({
73
200
  task
74
201
  }) => {
@@ -92,7 +219,6 @@ export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
92
219
  error: 0.2
93
220
  }
94
221
  });
95
- const hasElementTiming = supportsElementTiming();
96
222
  if (typeof window.gc !== 'function') {
97
223
  console.warn('window.gc is not available. Run with --js-flags=--expose-gc for consistent GC between iterations.');
98
224
  }
@@ -115,98 +241,50 @@ export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
115
241
  await settle();
116
242
  forceGC();
117
243
  const captures = [];
118
- const elementEntries = [];
119
- const elementResolvers = new Map();
120
-
121
- // Set up Element Timing observer
122
- let elementObserver = null;
123
- if (hasElementTiming) {
124
- elementObserver = new PerformanceObserver(list => {
125
- for (const entry of list.getEntries()) {
126
- elementEntries.push(entry);
127
- const resolver = elementResolvers.get(entry.identifier);
128
- if (resolver) {
129
- elementResolvers.delete(entry.identifier);
130
- resolver();
131
- }
132
- }
133
- });
134
- elementObserver.observe({
135
- type: 'element',
136
- buffered: false
137
- });
138
- }
139
- const waitForElementTiming = (identifier, timeout) => {
140
- if (!hasElementTiming) {
141
- console.warn(`waitForElementTiming("${identifier}"): Element Timing API is not supported. ` + 'Paint metrics will not be collected.');
142
- return Promise.resolve();
143
- }
144
- if (elementEntries.some(entry => entry.identifier === identifier)) {
145
- return Promise.resolve();
146
- }
147
- const {
148
- promise,
149
- resolve,
150
- reject
151
- } = Promise.withResolvers();
152
- const timeoutMs = timeout ?? 5000;
153
- const timer = timeoutMs > 0 && timeoutMs < Infinity ? setTimeout(() => {
154
- elementResolvers.delete(identifier);
155
- reject(new Error(`waitForElementTiming("${identifier}"): timed out after ${timeoutMs}ms. ` + 'Ensure the element has an `elementtiming` attribute and is visible in the viewport.'));
156
- }, timeoutMs) : undefined;
157
- elementResolvers.set(identifier, () => {
158
- if (timer) {
159
- clearTimeout(timer);
160
- }
161
- resolve();
162
- });
163
- return promise;
164
- };
165
- const iterationStart = performance.now();
166
- const container = document.createElement('div');
167
- document.body.appendChild(container);
168
- const root = ReactDOMClient.createRoot(container, {
244
+ const timing = createElementTimingWaiter();
245
+ const runtime = createCaseRuntime({
246
+ // Wrap the case in BenchProfiler so its renders are captured; the runtime mounts whatever
247
+ // renderFn returns (profiling passes the case bare).
248
+ renderFn: () => /*#__PURE__*/_jsx(BenchProfiler, {
249
+ captures: captures,
250
+ recording: recording,
251
+ children: renderFn()
252
+ }),
253
+ interaction,
254
+ context: {
255
+ waitForElementTiming: timing.waitForElementTiming,
256
+ pauseReactRecording: recording.pauseReactRecording,
257
+ resumeReactRecording: recording.resumeReactRecording
258
+ },
169
259
  // eslint-disable-next-line @typescript-eslint/no-loop-func
170
260
  onUncaughtError: error => {
171
261
  renderError = error;
172
262
  }
173
263
  });
174
- ReactDOM.flushSync(() => {
175
- root.render(/*#__PURE__*/_jsx(BenchProfiler, {
176
- captures: captures,
177
- recording: recording,
178
- children: renderFn()
179
- }));
180
- });
264
+ const iterationStart = performance.now();
265
+ runtime.mount();
181
266
  if (renderError) {
182
- elementObserver?.disconnect();
183
- root.unmount();
184
- container.remove();
267
+ timing.disconnect();
268
+ runtime.unmount();
185
269
  break;
186
270
  }
187
- if (interaction) {
188
- // eslint-disable-next-line no-await-in-loop
189
- await interaction({
190
- waitForElementTiming,
191
- pauseReactRecording: recording.pauseReactRecording,
192
- resumeReactRecording: recording.resumeReactRecording
193
- });
194
- }
271
+
272
+ // eslint-disable-next-line no-await-in-loop
273
+ await runtime.interact?.();
195
274
 
196
275
  // Wait for the bench sentinel paint entry (relies on test timeout)
197
276
  // eslint-disable-next-line no-await-in-loop
198
- await waitForElementTiming('default', 0);
277
+ await timing.waitForElementTiming('default', 0);
199
278
 
200
279
  // Close the final window and remember if any active window measured no renders.
201
280
  recording.finalizeWindow();
202
281
  if (recording.hadEmptyActiveWindow) {
203
282
  sawEmptyActiveWindow = true;
204
283
  }
205
- elementObserver?.disconnect();
206
- root.unmount();
207
- container.remove();
284
+ timing.disconnect();
285
+ runtime.unmount();
208
286
  if (!isWarmup) {
209
- for (const entry of elementEntries) {
287
+ for (const entry of timing.elementEntries) {
210
288
  // Skip paints that happened while recording was paused. Attribute by the paint's
211
289
  // `renderTime`, not by when the observer callback fired (which can lag the paint).
212
290
  if (!recording.activeAt(entry.renderTime)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mui/internal-benchmark",
3
- "version": "0.0.3-canary.7",
3
+ "version": "0.0.3-canary.8",
4
4
  "author": "MUI Team",
5
5
  "description": "Benchmark utilities for MUI projects. Internal package.",
6
6
  "repository": {
@@ -68,5 +68,5 @@
68
68
  }
69
69
  }
70
70
  },
71
- "gitSha": "0763444eee05140fa86809f0a4330c1550c9c3c6"
71
+ "gitSha": "f0490c402ec0640d9b3687fd1e1e827bfa9c1610"
72
72
  }
@@ -0,0 +1,2 @@
1
+ import type { BenchmarkCaseRuntime } from "./types.mjs";
2
+ export declare function runProfileSession(name: string, runtime: BenchmarkCaseRuntime): Promise<void>;
@@ -0,0 +1,95 @@
1
+ const PROFILE_PANEL_STYLE = ['position:fixed', 'top:0', 'left:0', 'right:0', 'z-index:2147483647', 'display:flex', 'gap:8px', 'align-items:center', 'box-sizing:border-box', 'padding:8px 12px', 'background:#1e1e1e', 'color:#fff', 'font:13px/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace', 'box-shadow:0 1px 6px rgba(0,0,0,0.5)'].join(';');
2
+ const PROFILE_BUTTON_STYLE = ['padding:4px 10px', 'border:1px solid #555', 'border-radius:4px', 'background:#333', 'color:#fff', 'cursor:pointer', 'font:inherit'].join(';');
3
+ function createProfileButton(label) {
4
+ const button = document.createElement('button');
5
+ button.type = 'button';
6
+ button.textContent = label;
7
+ button.style.cssText = PROFILE_BUTTON_STYLE;
8
+ return button;
9
+ }
10
+
11
+ // Interactive profiling session: instead of measuring, render a control panel with
12
+ // Render / Unmount / Run interaction / Finish buttons that drive the given case runtime. The
13
+ // component under test stays unmounted until the user clicks "Render", giving them time to start
14
+ // the DevTools profiler first. The returned promise resolves on "Finish", which is what keeps the
15
+ // Vitest test (and the headed browser window) alive in between; the caller cleans up afterwards.
16
+ export function runProfileSession(name, runtime) {
17
+ const panel = document.createElement('div');
18
+ panel.setAttribute('data-benchmark-profile-panel', '');
19
+ panel.style.cssText = PROFILE_PANEL_STYLE;
20
+ const title = document.createElement('span');
21
+ title.textContent = `⏱ ${name}`;
22
+ title.style.cssText = 'font-weight:600;white-space:nowrap';
23
+ const status = document.createElement('span');
24
+ status.style.cssText = 'margin-left:auto;opacity:0.85;white-space:nowrap';
25
+ const renderButton = createProfileButton('▶ Render');
26
+ const interactButton = runtime.interact ? createProfileButton('⚡ Run interaction') : null;
27
+ const finishButton = createProfileButton('✓ Finish');
28
+ if (interactButton) {
29
+ interactButton.disabled = true;
30
+ }
31
+ panel.appendChild(title);
32
+ panel.appendChild(renderButton);
33
+ if (interactButton) {
34
+ panel.appendChild(interactButton);
35
+ }
36
+ panel.appendChild(finishButton);
37
+ panel.appendChild(status);
38
+ document.body.appendChild(panel);
39
+
40
+ // Push page content below the fixed panel so it doesn't cover the component.
41
+ const spacer = document.createElement('div');
42
+ spacer.style.height = `${panel.offsetHeight}px`;
43
+ document.body.insertBefore(spacer, panel);
44
+ const setStatus = text => {
45
+ status.textContent = text;
46
+ };
47
+ const show = () => {
48
+ if (runtime.isMounted()) {
49
+ return;
50
+ }
51
+ runtime.mount();
52
+ renderButton.textContent = '■ Unmount';
53
+ if (interactButton) {
54
+ interactButton.disabled = false;
55
+ }
56
+ setStatus('rendered — capture your profile, then Unmount or Finish');
57
+ };
58
+ const hide = () => {
59
+ if (!runtime.isMounted()) {
60
+ return;
61
+ }
62
+ runtime.unmount();
63
+ renderButton.textContent = '▶ Render';
64
+ if (interactButton) {
65
+ interactButton.disabled = true;
66
+ }
67
+ setStatus('unmounted — Render again or Finish');
68
+ };
69
+ setStatus('idle — start the DevTools profiler, then click Render');
70
+ return new Promise(resolve => {
71
+ renderButton.addEventListener('click', () => runtime.isMounted() ? hide() : show());
72
+ if (interactButton) {
73
+ interactButton.addEventListener('click', async () => {
74
+ interactButton.disabled = true;
75
+ setStatus('running interaction…');
76
+ try {
77
+ await runtime.interact?.();
78
+ setStatus('interaction done');
79
+ } catch (error) {
80
+ setStatus(`interaction error: ${String(error)}`);
81
+ } finally {
82
+ if (runtime.isMounted()) {
83
+ interactButton.disabled = false;
84
+ }
85
+ }
86
+ });
87
+ }
88
+ finishButton.addEventListener('click', () => {
89
+ hide();
90
+ spacer.remove();
91
+ panel.remove();
92
+ resolve();
93
+ });
94
+ });
95
+ }
package/types.d.mts CHANGED
@@ -34,6 +34,16 @@ export interface InteractionContext {
34
34
  */
35
35
  resumeReactRecording: () => void;
36
36
  }
37
+ export interface BenchmarkCaseRuntime {
38
+ mount: () => void;
39
+ /**
40
+ * Runs the case's interaction with the harness context. Absent when the case has no interaction —
41
+ * the profiling panel uses its presence to decide whether to show the "Run interaction" button.
42
+ */
43
+ interact?: () => Promise<void>;
44
+ unmount: () => void;
45
+ isMounted: () => boolean;
46
+ }
37
47
  /**
38
48
  * Whether a custom metric measures a continuous value or a discrete count.
39
49
  * - `scalar` — continuous measurements (timings, sizes); compared with a relative noise band.
package/vitest.d.mts CHANGED
@@ -15,5 +15,22 @@ export interface CreateBenchmarkVitestConfigOptions {
15
15
  * Additional Chromium launch arguments.
16
16
  */
17
17
  launchArgs?: string[];
18
+ /**
19
+ * Run each `benchmark()` case as an interactive profiling session in a headed
20
+ * browser instead of the automated measurement loop. Each case renders a
21
+ * control panel with Render / Unmount / Finish buttons so you can start the
22
+ * DevTools profiler before the component mounts. Profiling auto-opens DevTools
23
+ * and runs headed (pass `viewport` to size the window). Also settable via
24
+ * `BENCHMARK_PROFILE=true`.
25
+ */
26
+ profile?: boolean;
27
+ /**
28
+ * Browser viewport — and, in profile mode, the matching window size. Defaults to 1920x1080 for
29
+ * both measurement and profiling. Also settable via `BENCHMARK_VIEWPORT` (e.g. `2560x1440`).
30
+ */
31
+ viewport?: {
32
+ width: number;
33
+ height: number;
34
+ };
18
35
  }
19
36
  export declare function createBenchmarkVitestConfig(options?: CreateBenchmarkVitestConfigOptions): ViteUserConfig;
package/vitest.mjs CHANGED
@@ -1,15 +1,56 @@
1
1
  import react from '@vitejs/plugin-react';
2
2
  import { playwright } from '@vitest/browser-playwright';
3
+ // Default viewport for all benchmark runs (measurement and profiling alike) — a desktop size is
4
+ // more representative for component benchmarks than Vitest's phone-sized 414x896 browser default.
5
+ const DEFAULT_VIEWPORT = {
6
+ width: 1920,
7
+ height: 1080
8
+ };
9
+
10
+ // Explicit viewport from the `viewport` option or the `BENCHMARK_VIEWPORT` env var
11
+ // (`<width>x<height>`); undefined when neither is set, so the caller can apply the default.
12
+ function resolveViewport(option) {
13
+ if (option) {
14
+ return option;
15
+ }
16
+ const env = process.env.BENCHMARK_VIEWPORT;
17
+ const match = env ? env.split('x') : null;
18
+ if (match && match.length === 2) {
19
+ return {
20
+ width: Number(match[0]),
21
+ height: Number(match[1])
22
+ };
23
+ }
24
+ return undefined;
25
+ }
26
+
27
+ // Chromium/V8 launch args shared by measurement and profiling, kept intentionally minimal.
28
+ // `--expose-gc` is required: the harness forces GC between iterations for clean, comparable
29
+ // timings. The backgrounding flags stop Chrome from throttling the (headless or occluded)
30
+ // benchmark tab, which would otherwise add large variance. Heavier "determinism" flags
31
+ // (`--no-opt`, `--predictable`, `--hash-seed`/`--random-seed`, `--disable-gpu`,
32
+ // `--enable-benchmarking`) were measured to slow renders ~40% and distort paint timing without
33
+ // reducing variance, so they are omitted — add them per project via `launchArgs` if a specific
34
+ // workload needs them.
35
+ const LAUNCH_ARGS = ['--js-flags=--expose-gc', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding'];
3
36
  export function createBenchmarkVitestConfig(options) {
4
37
  const {
5
38
  outputPath,
6
39
  baselinePath,
7
40
  launchArgs = []
8
41
  } = options ?? {};
42
+ const profile = options?.profile ?? process.env.BENCHMARK_PROFILE === 'true';
43
+ const viewport = resolveViewport(options?.viewport) ?? DEFAULT_VIEWPORT;
44
+
45
+ // Profiling adds DevTools on top of the shared args, plus a window sized to match the viewport —
46
+ // Vitest's `viewport` only sizes the iframe, so otherwise it's cropped/scrolled in the headed
47
+ // window instead of filling it. (Measurement is headless, so it has no window to size.)
48
+ const profileArgs = [...LAUNCH_ARGS, '--auto-open-devtools-for-tabs', `--window-size=${viewport.width},${viewport.height}`];
9
49
  return {
10
50
  plugins: [react()],
11
51
  define: {
12
- 'process.env.NODE_ENV': '"production"'
52
+ 'process.env.NODE_ENV': '"production"',
53
+ 'process.env.BENCHMARK_PROFILE': JSON.stringify(profile ? 'true' : '')
13
54
  },
14
55
  resolve: {
15
56
  dedupe: ['react', 'react-dom'],
@@ -21,29 +62,28 @@ export function createBenchmarkVitestConfig(options) {
21
62
  test: {
22
63
  browser: {
23
64
  enabled: true,
24
- headless: true,
65
+ headless: !profile,
66
+ // Profiling renders into a clean page: hide Vitest's browser runner UI
67
+ // so the orchestrator chrome doesn't clutter what you're profiling.
68
+ ui: profile ? false : undefined,
69
+ // Same viewport for both modes (DEFAULT_VIEWPORT unless overridden).
70
+ viewport,
25
71
  screenshotFailures: false,
26
72
  instances: [{
27
73
  browser: 'chromium',
28
- testTimeout: 120_000
74
+ // Profiling sessions are driven by hand, so give them effectively
75
+ // unlimited time instead of the measurement timeout.
76
+ testTimeout: profile ? 0 : 120_000
29
77
  }],
30
78
  provider: playwright({
31
79
  launchOptions: {
32
- args: [
33
- // V8 flags for deterministic JS execution
34
- '--js-flags=--expose-gc,--predictable,--no-opt,--predictable-gc-schedule,--no-concurrent-sweeping,--hash-seed=1,--random-seed=1,--max-old-space-size=4096',
35
- // Chromium flags to reduce renderer/compositor noise
36
- '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-networking',
37
- // Reduces environmental noise by disabling field trials,
38
- // for more consistent profiling results.
39
- '--enable-benchmarking',
40
- // Forces software rendering instead of GPU, which is more deterministic.
41
- '--disable-gpu', ...launchArgs]
80
+ args: [...(profile ? profileArgs : LAUNCH_ARGS), ...launchArgs]
42
81
  }
43
82
  })
44
83
  },
45
84
  fileParallelism: false,
46
- reporters: ['default', ['@mui/internal-benchmark/reporter', {
85
+ // Profiling sessions don't measure anything, so skip the results reporter.
86
+ reporters: profile ? ['default'] : ['default', ['@mui/internal-benchmark/reporter', {
47
87
  outputPath,
48
88
  baselinePath
49
89
  }]],