@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 +0 -1
- package/README.md +34 -0
- package/index.mjs +157 -79
- package/package.json +2 -2
- package/profileSession.d.mts +2 -0
- package/profileSession.mjs +95 -0
- package/types.d.mts +10 -0
- package/vitest.d.mts +17 -0
- package/vitest.mjs +54 -14
package/CHANGELOG.md
CHANGED
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__*/
|
|
43
|
+
return /*#__PURE__*/_jsx(React.Profiler, {
|
|
43
44
|
id: "bench",
|
|
44
45
|
onRender: onRender,
|
|
45
|
-
children:
|
|
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
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
captures: captures,
|
|
177
|
-
recording: recording,
|
|
178
|
-
children: renderFn()
|
|
179
|
-
}));
|
|
180
|
-
});
|
|
264
|
+
const iterationStart = performance.now();
|
|
265
|
+
runtime.mount();
|
|
181
266
|
if (renderError) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
container.remove();
|
|
267
|
+
timing.disconnect();
|
|
268
|
+
runtime.unmount();
|
|
185
269
|
break;
|
|
186
270
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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.
|
|
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": "
|
|
71
|
+
"gitSha": "f0490c402ec0640d9b3687fd1e1e827bfa9c1610"
|
|
72
72
|
}
|
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}]],
|