@mui/internal-benchmark 0.0.3-canary.2 → 0.0.3-canary.21

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/index.mjs CHANGED
@@ -11,28 +11,39 @@ import { expect, it } from 'vitest';
11
11
  import * as ReactDOMClient from 'react-dom/client'; // aliased to react-dom/profiling by Vite
12
12
  import * as ReactDOM from 'react-dom';
13
13
  import { ElementTiming } from "./ElementTiming.mjs";
14
+ import { ScalarMetric } from "./ScalarMetric.mjs";
15
+ import { metricsGate } from "./metricsGate.mjs";
16
+ import { createReactRecordingControls } from "./reactRecording.mjs";
17
+ import { runProfileSession } from "./profileSession.mjs";
14
18
  // Import for TaskMeta augmentation side effect
15
19
  import "./taskMetaAugmentation.mjs";
16
20
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
17
21
  export { ElementTiming } from "./ElementTiming.mjs";
22
+ export { Metric } from "./Metric.mjs";
23
+ export { ScalarMetric };
24
+ export { DiscreteMetric } from "./DiscreteMetric.mjs";
18
25
  function BenchProfiler({
19
26
  captures,
27
+ recording,
20
28
  children
21
29
  }) {
22
30
  const onRender = React.useCallback((id, phase, actualDuration, _baseDuration, startTime) => {
23
- captures.push({
24
- id,
25
- phase,
26
- actualDuration,
27
- startTime
28
- });
29
- }, [captures]);
30
- return /*#__PURE__*/_jsxs(React.Profiler, {
31
+ // Skip renders captured while React recording is paused (e.g. the mount when the benchmark
32
+ // starts paused, or a span the interaction explicitly excludes).
33
+ if (recording.active) {
34
+ captures.push({
35
+ id,
36
+ phase,
37
+ actualDuration,
38
+ startTime
39
+ });
40
+ recording.markRendered();
41
+ }
42
+ }, [captures, recording]);
43
+ return /*#__PURE__*/_jsx(React.Profiler, {
31
44
  id: "bench",
32
45
  onRender: onRender,
33
- children: [children, _ElementTiming || (_ElementTiming = /*#__PURE__*/_jsx(ElementTiming, {
34
- name: "default"
35
- }))]
46
+ children: children
36
47
  });
37
48
  }
38
49
 
@@ -54,9 +65,137 @@ function settle() {
54
65
  function supportsElementTiming() {
55
66
  return PerformanceObserver.supportedEntryTypes.includes('element');
56
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
+ }
57
172
  export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
58
173
  const interaction = typeof interactionOrOptions === 'function' ? interactionOrOptions : undefined;
59
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
+ }
60
199
  it(name, async ({
61
200
  task
62
201
  }) => {
@@ -64,108 +203,99 @@ export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
64
203
  const warmupRuns = options?.warmupRuns ?? 10;
65
204
  const totalRuns = warmupRuns + runs;
66
205
  const iterations = [];
67
- const hasElementTiming = supportsElementTiming();
206
+
207
+ // Paint timings are recorded as one harness-owned `bench:paint` metric: the default sentinel
208
+ // is the base series (`bench:paint`) and named `elementtiming` markers are sub-series
209
+ // (`bench:paint#grid-header`, …), all sharing a single definition. Paint is informational (no
210
+ // alarm): it dominates each test's total duration, so a per-test paint alarm just duplicates the
211
+ // Duration regression signal and floods the report on any broadly-regressed run.
212
+ const paint = new ScalarMetric({
213
+ name: 'bench:paint',
214
+ format: {
215
+ style: 'unit',
216
+ unit: 'millisecond',
217
+ maximumFractionDigits: 2
218
+ }
219
+ });
68
220
  if (typeof window.gc !== 'function') {
69
221
  console.warn('window.gc is not available. Run with --js-flags=--expose-gc for consistent GC between iterations.');
70
222
  }
71
223
  let renderError = null;
224
+ // Set if any iteration had a recording window that was active yet captured no renders.
225
+ let sawEmptyActiveWindow = false;
72
226
  for (let i = 0; i < totalRuns; i += 1) {
73
227
  const isWarmup = i < warmupRuns;
74
228
 
229
+ // Custom metrics recorded inside the benchmark honor warmup exclusion through the gate, the
230
+ // same way renders and `bench:paint` are excluded during warmup.
231
+ metricsGate.setRecordingEnabled(task, !isWarmup);
232
+
233
+ // Per-iteration switch for the harness's React render/paint recording. Starts paused when
234
+ // `reactRecordingPaused` is set; the interaction callback drives it from there.
235
+ const recording = createReactRecordingControls(!(options?.reactRecordingPaused ?? false));
236
+
75
237
  // Drain event loop from previous unmount, then double GC for thorough cleanup
76
238
  // eslint-disable-next-line no-await-in-loop
77
239
  await settle();
78
240
  forceGC();
79
241
  const captures = [];
80
- const elementEntries = [];
81
- const elementResolvers = new Map();
82
-
83
- // Set up Element Timing observer
84
- let elementObserver = null;
85
- if (hasElementTiming) {
86
- elementObserver = new PerformanceObserver(list => {
87
- for (const entry of list.getEntries()) {
88
- elementEntries.push(entry);
89
- const resolver = elementResolvers.get(entry.identifier);
90
- if (resolver) {
91
- elementResolvers.delete(entry.identifier);
92
- resolver();
93
- }
94
- }
95
- });
96
- elementObserver.observe({
97
- type: 'element',
98
- buffered: false
99
- });
100
- }
101
- const waitForElementTiming = (identifier, timeout) => {
102
- if (!hasElementTiming) {
103
- console.warn(`waitForElementTiming("${identifier}"): Element Timing API is not supported. ` + 'Paint metrics will not be collected.');
104
- return Promise.resolve();
105
- }
106
- if (elementEntries.some(entry => entry.identifier === identifier)) {
107
- return Promise.resolve();
108
- }
109
- const {
110
- promise,
111
- resolve,
112
- reject
113
- } = Promise.withResolvers();
114
- const timeoutMs = timeout ?? 5000;
115
- const timer = timeoutMs > 0 && timeoutMs < Infinity ? setTimeout(() => {
116
- elementResolvers.delete(identifier);
117
- reject(new Error(`waitForElementTiming("${identifier}"): timed out after ${timeoutMs}ms. ` + 'Ensure the element has an `elementtiming` attribute and is visible in the viewport.'));
118
- }, timeoutMs) : undefined;
119
- elementResolvers.set(identifier, () => {
120
- if (timer) {
121
- clearTimeout(timer);
122
- }
123
- resolve();
124
- });
125
- return promise;
126
- };
127
- const iterationStart = performance.now();
128
- const container = document.createElement('div');
129
- document.body.appendChild(container);
130
- const root = ReactDOMClient.createRoot(container, {
242
+ const timing = createElementTimingWaiter();
243
+ const runtime = createCaseRuntime({
244
+ // Wrap the case in BenchProfiler so its renders are captured; the runtime mounts whatever
245
+ // renderFn returns (profiling passes the case bare).
246
+ renderFn: () => /*#__PURE__*/_jsx(BenchProfiler, {
247
+ captures: captures,
248
+ recording: recording,
249
+ children: renderFn()
250
+ }),
251
+ interaction,
252
+ context: {
253
+ waitForElementTiming: timing.waitForElementTiming,
254
+ pauseReactRecording: recording.pauseReactRecording,
255
+ resumeReactRecording: recording.resumeReactRecording
256
+ },
131
257
  // eslint-disable-next-line @typescript-eslint/no-loop-func
132
258
  onUncaughtError: error => {
133
259
  renderError = error;
134
260
  }
135
261
  });
136
- ReactDOM.flushSync(() => {
137
- root.render(/*#__PURE__*/_jsx(BenchProfiler, {
138
- captures: captures,
139
- children: renderFn()
140
- }));
141
- });
262
+ const iterationStart = performance.now();
263
+ runtime.mount();
142
264
  if (renderError) {
143
- elementObserver?.disconnect();
144
- root.unmount();
145
- container.remove();
265
+ timing.disconnect();
266
+ runtime.unmount();
146
267
  break;
147
268
  }
148
- if (interaction) {
149
- // eslint-disable-next-line no-await-in-loop
150
- await interaction({
151
- waitForElementTiming
152
- });
153
- }
269
+
270
+ // eslint-disable-next-line no-await-in-loop
271
+ await runtime.interact?.();
154
272
 
155
273
  // Wait for the bench sentinel paint entry (relies on test timeout)
156
274
  // eslint-disable-next-line no-await-in-loop
157
- await waitForElementTiming('default', 0);
158
- elementObserver?.disconnect();
159
- root.unmount();
160
- container.remove();
275
+ await timing.waitForElementTiming('default', 0);
276
+
277
+ // Close the final window and remember if any active window measured no renders.
278
+ recording.finalizeWindow();
279
+ if (recording.hadEmptyActiveWindow) {
280
+ sawEmptyActiveWindow = true;
281
+ }
282
+ timing.disconnect();
283
+ runtime.unmount();
161
284
  if (!isWarmup) {
162
- const metrics = elementEntries.map(entry => ({
163
- name: `paint:${entry.identifier}`,
164
- value: entry.renderTime - iterationStart
165
- }));
285
+ for (const entry of timing.elementEntries) {
286
+ // Skip paints that happened while recording was paused. Attribute by the paint's
287
+ // `paintTime`, not by when the observer callback fired (which can lag the paint).
288
+ if (!recording.activeAt(entry.paintTime)) {
289
+ continue;
290
+ }
291
+ // The default sentinel is the base series; named markers become sub-series.
292
+ const id = entry.identifier === 'default' ? undefined : entry.identifier;
293
+ paint.record(entry.paintTime - iterationStart, id !== undefined ? {
294
+ id
295
+ } : undefined);
296
+ }
166
297
  iterations.push({
167
- renders: captures,
168
- metrics
298
+ renders: captures
169
299
  });
170
300
  }
171
301
  if (options?.afterEach) {
@@ -179,8 +309,9 @@ export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
179
309
  throw renderError;
180
310
  }
181
311
 
182
- // Validate that at least one render was recorded
183
- expect(iterations[0].renders.length, 'No renders were recorded during benchmark').toBeGreaterThan(0);
312
+ // Every active recording window must capture at least one render. Windows where recording was
313
+ // never running (e.g. a fully-paused, metric-only benchmark) are not checked.
314
+ expect(sawEmptyActiveWindow, 'React recording was active but captured no renders. If you only measure imperative DOM ' + 'updates or custom metrics, keep recording paused (reactRecordingPaused) instead of resuming.').toBe(false);
184
315
 
185
316
  // Validate all iterations produced the same render events (count + order).
186
317
  // This runs after meta is set so the reporter can still display results on failure.
@@ -0,0 +1,7 @@
1
+ import type { RunnerTestCase } from 'vitest';
2
+ export declare const metricsGate: {
3
+ /** Whether custom-metric recording is currently enabled for `test`. Defaults to `true`. */
4
+ isRecordingEnabled(test: RunnerTestCase): boolean;
5
+ /** Enable or disable custom-metric recording for `test`. */
6
+ setRecordingEnabled(test: RunnerTestCase, enabled: boolean): void;
7
+ };
@@ -0,0 +1,21 @@
1
+ // Internal — not exported from the package, not user-facing. The `benchmark()` harness toggles
2
+ // recording per test (off during warmup iterations) and `Metric.record()` consults it, so custom
3
+ // metrics recorded inside a benchmark honor the same warmup exclusion as renders and `bench:paint`.
4
+ //
5
+ // Storage tracks the *disabled* tests so absence means enabled: a test with no entry — e.g. a
6
+ // standalone `it()` loop that never goes through the harness — records normally by default.
7
+ const disabled = new WeakSet();
8
+ export const metricsGate = {
9
+ /** Whether custom-metric recording is currently enabled for `test`. Defaults to `true`. */
10
+ isRecordingEnabled(test) {
11
+ return !disabled.has(test);
12
+ },
13
+ /** Enable or disable custom-metric recording for `test`. */
14
+ setRecordingEnabled(test, enabled) {
15
+ if (enabled) {
16
+ disabled.delete(test);
17
+ } else {
18
+ disabled.add(test);
19
+ }
20
+ }
21
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mui/internal-benchmark",
3
- "version": "0.0.3-canary.2",
3
+ "version": "0.0.3-canary.21",
4
4
  "author": "MUI Team",
5
5
  "description": "Benchmark utilities for MUI projects. Internal package.",
6
6
  "repository": {
@@ -9,11 +9,11 @@
9
9
  "directory": "packages/benchmark"
10
10
  },
11
11
  "dependencies": {
12
- "@babel/runtime": "^7.29.2",
13
- "@vitejs/plugin-react": "^6.0.1",
12
+ "@babel/runtime": "^7.29.7",
13
+ "@vitejs/plugin-react": "^6.1.0",
14
14
  "env-ci": "^11.2.0",
15
- "execa": "^9.6.1",
16
- "zod": "^4.3.6"
15
+ "execa": "^10.0.1",
16
+ "zod": "^4.4.3"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "@vitest/browser-playwright": ">=4.1",
@@ -68,5 +68,5 @@
68
68
  }
69
69
  }
70
70
  },
71
- "gitSha": "2e4759d55d99ba6104718b973ed93e965326be98"
71
+ "gitSha": "9e0cb003bd909d9f1b5f9f33b1706f3ce1627ea8"
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
+ }
@@ -0,0 +1,30 @@
1
+ export interface ReactRecordingControls {
2
+ /** Whether React render/paint recording is active right now — the synchronous gate for renders. */
3
+ readonly active: boolean;
4
+ /** Whether any active recording window closed without capturing a render. */
5
+ readonly hadEmptyActiveWindow: boolean;
6
+ /**
7
+ * Whether recording was active at `time` (a `performance.now()` timestamp). Paint entries are
8
+ * observed asynchronously, so they are attributed by their `paintTime` rather than by the
9
+ * recording state at the moment the observer callback happens to fire.
10
+ */
11
+ activeAt(time: number): boolean;
12
+ /** Note that a render was captured in the current window. Called by the harness from `onRender`. */
13
+ markRendered(): void;
14
+ /** Close the final window at the end of the iteration (validates it if recording is still active). */
15
+ finalizeWindow(): void;
16
+ /** Pause React render/paint recording. Throws if recording is already paused. */
17
+ pauseReactRecording(): void;
18
+ /** Resume React render/paint recording. Throws if recording is already active. */
19
+ resumeReactRecording(): void;
20
+ }
21
+ /**
22
+ * Creates the per-iteration switch that turns the harness's React render/paint recording on and
23
+ * off. The interaction callback drives it via `pauseReactRecording`/`resumeReactRecording`; the
24
+ * strict state machine (each throws when called in the wrong state) catches unbalanced pairs early.
25
+ *
26
+ * It also tracks whether each *active* window captured at least one render, so the harness can flag
27
+ * a window that was recording but measured nothing — while leaving fully-paused (metric-only)
28
+ * benchmarks alone.
29
+ */
30
+ export declare function createReactRecordingControls(initiallyActive: boolean): ReactRecordingControls;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Creates the per-iteration switch that turns the harness's React render/paint recording on and
3
+ * off. The interaction callback drives it via `pauseReactRecording`/`resumeReactRecording`; the
4
+ * strict state machine (each throws when called in the wrong state) catches unbalanced pairs early.
5
+ *
6
+ * It also tracks whether each *active* window captured at least one render, so the harness can flag
7
+ * a window that was recording but measured nothing — while leaving fully-paused (metric-only)
8
+ * benchmarks alone.
9
+ */
10
+ export function createReactRecordingControls(initiallyActive) {
11
+ let active = initiallyActive;
12
+ let currentWindowHasRender = false;
13
+ let emptyActiveWindow = false;
14
+ // Transitions in chronological order. The implicit state before the first toggle is
15
+ // `initiallyActive`; `activeAt` replays this to attribute a paint to its render time.
16
+ const transitions = [];
17
+
18
+ // Flag the window being closed if it was recording yet captured nothing.
19
+ function closeWindowIfActive() {
20
+ if (active && !currentWindowHasRender) {
21
+ emptyActiveWindow = true;
22
+ }
23
+ }
24
+ return {
25
+ get active() {
26
+ return active;
27
+ },
28
+ get hadEmptyActiveWindow() {
29
+ return emptyActiveWindow;
30
+ },
31
+ activeAt(time) {
32
+ let result = initiallyActive;
33
+ for (const transition of transitions) {
34
+ if (transition.time > time) {
35
+ break;
36
+ }
37
+ result = transition.active;
38
+ }
39
+ return result;
40
+ },
41
+ markRendered() {
42
+ currentWindowHasRender = true;
43
+ },
44
+ finalizeWindow() {
45
+ closeWindowIfActive();
46
+ },
47
+ pauseReactRecording() {
48
+ // Stamp first — before the guard and bookkeeping — so the closing window ends as early as
49
+ // possible and excludes pause's own overhead. In the throw path it is simply discarded.
50
+ const now = performance.now();
51
+ if (!active) {
52
+ throw new Error('pauseReactRecording() called but React recording is already paused.');
53
+ }
54
+ closeWindowIfActive();
55
+ active = false;
56
+ transitions.push({
57
+ time: now,
58
+ active: false
59
+ });
60
+ },
61
+ resumeReactRecording() {
62
+ if (active) {
63
+ throw new Error('resumeReactRecording() called but React recording is already active.');
64
+ }
65
+ active = true;
66
+ currentWindowHasRender = false;
67
+ // Stamp last — after the bookkeeping — so the new window starts as late as possible and
68
+ // excludes resume's own overhead.
69
+ const now = performance.now();
70
+ transitions.push({
71
+ time: now,
72
+ active: true
73
+ });
74
+ }
75
+ };
76
+ }
package/reporter.d.mts CHANGED
@@ -10,11 +10,13 @@ export interface BenchmarkReporterOptions {
10
10
  }
11
11
  declare class BenchmarkReporter implements Reporter {
12
12
  private benchmarks;
13
+ private metricDefinitions;
13
14
  private outputPath;
14
15
  private upload;
15
16
  private baselinePath;
16
17
  private hasFailures;
17
18
  constructor(options?: BenchmarkReporterOptions);
19
+ onTestRunStart(): void;
18
20
  onTestCaseResult(testCase: TestCase): void;
19
21
  onTestRunEnd(): Promise<void>;
20
22
  }