@mui/internal-benchmark 0.0.3-canary.6 → 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.
@@ -0,0 +1,15 @@
1
+ import { Metric } from "./Metric.mjs";
2
+ import type { MetricConfig, MetricKind } from "./types.mjs";
3
+ /**
4
+ * A discrete count of events or occurrences. Compared against a baseline as an exact integer
5
+ * (any change is significant — there is no noise band), and formatted as a whole number by default.
6
+ *
7
+ * ```ts
8
+ * const clicks = new DiscreteMetric({ name: 'button_clicks' });
9
+ * clicks.record(countClicks());
10
+ * ```
11
+ */
12
+ export declare class DiscreteMetric extends Metric {
13
+ readonly kind: MetricKind;
14
+ constructor(config: MetricConfig | string);
15
+ }
@@ -0,0 +1,24 @@
1
+ import { Metric } from "./Metric.mjs";
2
+ /**
3
+ * A discrete count of events or occurrences. Compared against a baseline as an exact integer
4
+ * (any change is significant — there is no noise band), and formatted as a whole number by default.
5
+ *
6
+ * ```ts
7
+ * const clicks = new DiscreteMetric({ name: 'button_clicks' });
8
+ * clicks.record(countClicks());
9
+ * ```
10
+ */
11
+ export class DiscreteMetric extends Metric {
12
+ kind = 'discrete';
13
+ constructor(config) {
14
+ const resolved = typeof config === 'string' ? {
15
+ name: config
16
+ } : config;
17
+ super({
18
+ ...resolved,
19
+ format: resolved.format ?? {
20
+ maximumFractionDigits: 0
21
+ }
22
+ });
23
+ }
24
+ }
package/LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- The MIT License (MIT)
1
+ MIT License
2
2
 
3
3
  Copyright (c) 2019 Material-UI SAS
4
4
 
package/Metric.d.mts ADDED
@@ -0,0 +1,24 @@
1
+ import type { MetricConfig, MetricKind } from "./types.mjs";
2
+ import "./taskMetaAugmentation.mjs";
3
+ export interface MetricRecordOptions {
4
+ /** Sub-series label. Recorded under `${name}#${id}` in the report; omit for the base series. */
5
+ id?: string;
6
+ }
7
+ /**
8
+ * Base class for custom benchmark metrics. Use `ScalarMetric` or `DiscreteMetric`.
9
+ *
10
+ * Records are tied to the test that is running when `record()` is called (resolved via
11
+ * `getCurrentTest()`), so a single instance can be declared at module scope and reused across
12
+ * tests and loop iterations, inside or outside React.
13
+ */
14
+ export declare abstract class Metric {
15
+ abstract readonly kind: MetricKind;
16
+ readonly name: string;
17
+ protected readonly config: MetricConfig;
18
+ constructor(config: MetricConfig | string);
19
+ /**
20
+ * Records a single measured value. Samples accumulate in browser memory and are aggregated
21
+ * once when the test finishes. Pass `options.id` to split into a labeled sub-series.
22
+ */
23
+ record(value: number, options?: MetricRecordOptions): void;
24
+ }
package/Metric.mjs ADDED
@@ -0,0 +1,89 @@
1
+ import { onTestFinished, TestRunner } from 'vitest';
2
+ import { aggregateSamples } from "./stats.mjs";
3
+ import { metricsGate } from "./metricsGate.mjs";
4
+ // Import for TaskMeta augmentation side effect
5
+ import "./taskMetaAugmentation.mjs";
6
+ // Raw samples never cross the browser→runner boundary. They accumulate here per test (keyed by
7
+ // the running task so a module-scoped metric shared across tests never mixes data) and are
8
+ // aggregated into compact stats by an `onTestFinished` hook before being written to `task.meta`.
9
+ const accumulators = new WeakMap();
10
+ function flush(test, accumulator) {
11
+ const store = {};
12
+ for (const [name, entry] of accumulator) {
13
+ const series = {};
14
+ for (const [seriesId, samples] of entry.series) {
15
+ series[seriesId] = {
16
+ ...aggregateSamples(samples),
17
+ count: samples.length
18
+ };
19
+ }
20
+ store[name] = {
21
+ kind: entry.kind,
22
+ config: entry.config,
23
+ series
24
+ };
25
+ }
26
+ test.meta.benchmarkMetrics = store;
27
+ }
28
+ /**
29
+ * Base class for custom benchmark metrics. Use `ScalarMetric` or `DiscreteMetric`.
30
+ *
31
+ * Records are tied to the test that is running when `record()` is called (resolved via
32
+ * `getCurrentTest()`), so a single instance can be declared at module scope and reused across
33
+ * tests and loop iterations, inside or outside React.
34
+ */
35
+ export class Metric {
36
+ constructor(config) {
37
+ this.config = typeof config === 'string' ? {
38
+ name: config
39
+ } : config;
40
+ this.name = this.config.name;
41
+ if (this.name.includes('#')) {
42
+ throw new Error(`Metric name "${this.name}" must not contain "#" — it is reserved as the sub-series separator.`);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Records a single measured value. Samples accumulate in browser memory and are aggregated
48
+ * once when the test finishes. Pass `options.id` to split into a labeled sub-series.
49
+ */
50
+ record(value, options) {
51
+ const test = TestRunner.getCurrentTest();
52
+ if (!test) {
53
+ throw new Error(`${this.constructor.name}.record() must be called inside a running Vitest test.`);
54
+ }
55
+
56
+ // The harness disables the gate during warmup iterations so custom metrics recorded inside a
57
+ // benchmark honor the same warmup exclusion as renders and `bench:paint`. Standalone `it()`
58
+ // loops never touch the gate, so they keep recording every value.
59
+ if (!metricsGate.isRecordingEnabled(test)) {
60
+ return;
61
+ }
62
+ let accumulator = accumulators.get(test);
63
+ if (!accumulator) {
64
+ const created = new Map();
65
+ accumulator = created;
66
+ accumulators.set(test, created);
67
+ onTestFinished(() => flush(test, created));
68
+ }
69
+ let entry = accumulator.get(this.name);
70
+ if (!entry) {
71
+ entry = {
72
+ owner: this,
73
+ kind: this.kind,
74
+ config: this.config,
75
+ series: new Map()
76
+ };
77
+ accumulator.set(this.name, entry);
78
+ } else if (entry.owner !== this) {
79
+ throw new Error(`Two metrics share the name "${this.name}". Metric names must be unique; reuse a single instance instead.`);
80
+ }
81
+ const seriesId = options?.id ?? '';
82
+ let samples = entry.series.get(seriesId);
83
+ if (!samples) {
84
+ samples = [];
85
+ entry.series.set(seriesId, samples);
86
+ }
87
+ samples.push(value);
88
+ }
89
+ }
package/README.md CHANGED
@@ -61,9 +61,44 @@ benchmark(
61
61
  );
62
62
  ```
63
63
 
64
+ ### Scoping which renders are measured
65
+
66
+ By default a benchmark records every React render and paint, from the mount through the whole interaction. To measure only part of an interaction — or to exclude the mount — pause and resume recording from the interaction callback:
67
+
68
+ ```tsx
69
+ benchmark(
70
+ 'Combobox type',
71
+ () => <Combobox />,
72
+ async ({ pauseReactRecording, resumeReactRecording, waitForElementTiming }) => {
73
+ pauseReactRecording(); // stop recording the settling re-renders
74
+ await openMenu();
75
+ resumeReactRecording(); // measure only what follows
76
+ await type('hello');
77
+ await waitForElementTiming('results');
78
+ },
79
+ );
80
+ ```
81
+
82
+ `pauseReactRecording()` / `resumeReactRecording()` toggle only the harness's React render and `bench:paint` recording — your own custom metrics keep recording. They are a strict pair: pausing while already paused, or resuming while already active, throws (this catches unbalanced calls early).
83
+
84
+ To exclude the mount itself, start paused with the `reactRecordingPaused` option and resume at the point you care about — the mount is captured before the interaction callback runs, so pausing inside the callback can't drop it:
85
+
86
+ ```tsx
87
+ benchmark(
88
+ 'Combobox type',
89
+ () => <Combobox />,
90
+ async ({ resumeReactRecording }) => {
91
+ await openMenu(); // mount + open: not recorded
92
+ resumeReactRecording();
93
+ await type('hello'); // only these renders/paint recorded
94
+ },
95
+ { reactRecordingPaused: true },
96
+ );
97
+ ```
98
+
64
99
  ### Paint metrics
65
100
 
66
- By default, every benchmark captures a `paint:default` metric — the time from iteration start until the browser actually paints the rendered output. This uses the [Element Timing API](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming) via an invisible sentinel element that the benchmark harness renders automatically.
101
+ By default, every benchmark captures a `bench:paint` metric — the time from iteration start until the browser actually paints the rendered output. This uses the [Element Timing API](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming) via an invisible sentinel element that the benchmark harness renders automatically. The harness owns the `bench:` namespace, so avoid it for your own metric names.
67
102
 
68
103
  You can track additional paint metrics by placing `<ElementTiming>` markers and awaiting them in an interaction callback. The component renders an invisible `<span>` that fires in the same paint frame as its surrounding content.
69
104
 
@@ -88,16 +123,79 @@ benchmark(
88
123
  );
89
124
  ```
90
125
 
91
- This produces a `paint:my-component` metric alongside the automatic `paint:default`.
126
+ This produces a `bench:paint#my-component` sub-series alongside the automatic `bench:paint`.
92
127
 
93
128
  `waitForElementTiming` accepts an optional `timeout` in milliseconds (default: 5000). Pass `0` or `Infinity` to rely on the test timeout instead.
94
129
 
130
+ ### Custom metrics
131
+
132
+ Record your own measurements — a timing, a count, anything measured inside or outside React — from a plain `it()` loop or from inside a `benchmark()`. There are two primitives:
133
+
134
+ - `ScalarMetric` — a continuous value (timings, sizes). Aggregated as mean ± standard deviation with IQR outlier removal, and compared against a baseline with a relative noise band.
135
+ - `DiscreteMetric` — a count of events. Compared as an exact integer (any change is significant) and formatted as a whole number.
136
+
137
+ Both record values with `record(value)`. `ScalarMetric` additionally offers `time()`/`timeEnd()` — a `console.time`-style shortcut that records the elapsed milliseconds for you.
138
+
139
+ ```tsx
140
+ import { it } from 'vitest';
141
+ import { ScalarMetric, DiscreteMetric } from '@mui/internal-benchmark';
142
+
143
+ const duration = new ScalarMetric({
144
+ name: 'work_duration',
145
+ format: { style: 'unit', unit: 'millisecond' }, // Intl.NumberFormatOptions
146
+ alarm: { direction: 'lowerIsBetter', warn: 0.1, error: 0.25 }, // warn >10%, error >25%
147
+ });
148
+
149
+ const clicks = new DiscreteMetric({ name: 'button_clicks' });
150
+
151
+ it('measures work', () => {
152
+ for (let i = 0; i < 100; i += 1) {
153
+ duration.time();
154
+ runWork();
155
+ duration.timeEnd(); // records the elapsed milliseconds
156
+
157
+ clicks.record(countClicks()); // a discrete count per run
158
+ }
159
+ });
160
+ ```
161
+
162
+ A metric is declared once (typically at module scope) and reused across tests and iterations. `record()` attaches the value to whichever test is running, so the same instance works in any `it()`.
163
+
164
+ You can also `record()` or `time()` from inside a `benchmark()` render function or interaction callback. Values recorded during warmup iterations are excluded automatically, just like renders and `bench:paint`, so a metric recorded once per iteration yields exactly `runs` samples.
165
+
166
+ #### Metric configuration
167
+
168
+ - `name` — the metric's report key (**required**).
169
+ - `format` — an [`Intl.NumberFormatOptions`](https://developer.mozilla.org/en-US/docs/Web/API/Intl/NumberFormat/NumberFormat) object used to display the value.
170
+ - `alarm` — opts the metric into regression flagging. Omit it and the metric is informational (its diff is shown but never flagged). Holds:
171
+ - `direction` — `'lowerIsBetter'` (default) or `'higherIsBetter'`.
172
+ - `warn` — softer band; a regression past it is flagged as a warning.
173
+ - `error` — harder band; a regression past it is flagged as an error. Defaults to the dashboard's global noise band only when both `warn` and `error` are omitted; with only `warn` set there is no error band (warning-only).
174
+ - Bands are relative fractions for scalar metrics (`0.1` = 10%) and absolute count deltas for discrete metrics (`1`, `2`). Either band is optional.
175
+
176
+ Alarms are evaluated against the baseline when the PR comment is generated, not during the local `vitest run` — a regression never fails the test suite locally. In the PR comment, `error`-band regressions surface as failures and `warn`-band regressions as warnings.
177
+
178
+ #### Sub-series
179
+
180
+ Pass `record(value, { id })` to split one metric into labeled sub-series, reported as `name#id`. For `ScalarMetric.time()`/`timeEnd()`, pass a label that maps to the same `id`:
181
+
182
+ ```tsx
183
+ const phase = new ScalarMetric({ name: 'render_phase' });
184
+
185
+ phase.time('header');
186
+ renderHeader();
187
+ phase.timeEnd('header'); // -> render_phase#header
188
+ ```
189
+
190
+ Custom metrics are aggregated in the browser and only the resulting stats cross to the runner, so the amount of data is independent of how many values you record.
191
+
95
192
  ### Options
96
193
 
97
194
  ```tsx
98
195
  benchmark('name', renderFn, interaction, {
99
196
  runs: 20, // measurement iterations (default: 20)
100
197
  warmupRuns: 10, // warmup iterations before measuring (default: 10)
198
+ reactRecordingPaused: false, // start with React render/paint recording paused (default: false)
101
199
  afterEach: () => {
102
200
  /* cleanup between iterations */
103
201
  },
@@ -110,6 +208,38 @@ benchmark('name', renderFn, interaction, {
110
208
  vitest run
111
209
  ```
112
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
+
113
243
  ### Configuration
114
244
 
115
245
  `createBenchmarkVitestConfig` accepts:
@@ -117,6 +247,8 @@ vitest run
117
247
  - `outputPath` — path for JSON results (default: `benchmarks/results.json`). Also settable via `BENCHMARK_OUTPUT_PATH`.
118
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`.
119
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`).
120
252
 
121
253
  To override standard Vitest options (e.g. `include`, `testTimeout`, `headless`), use `mergeConfig`:
122
254
 
@@ -148,5 +280,7 @@ The feature is opt-in — without `BENCHMARK_BASELINE_PATH` (or the `baselinePat
148
280
 
149
281
  - `benchmark` — define a benchmark test case
150
282
  - `ElementTiming` — invisible marker component for paint timing (renders a `<span>` tracked by the Element Timing API)
283
+ - `ScalarMetric` — record a continuous custom measurement (with a `console.time`-style timing helper)
284
+ - `DiscreteMetric` — record a discrete custom count
151
285
  - `createBenchmarkVitestConfig` — create a Vitest config with browser benchmarking defaults
152
286
  - `BenchmarkReporter` — Vitest reporter that collects and outputs benchmark results
@@ -0,0 +1,23 @@
1
+ import { Metric } from "./Metric.mjs";
2
+ import type { MetricKind } from "./types.mjs";
3
+ /**
4
+ * A continuous measurement (timings, sizes, …). Samples are aggregated with mean ± standard
5
+ * deviation and IQR outlier removal, and compared against a baseline with a relative noise band.
6
+ *
7
+ * It offers a `console.time`-style timing helper:
8
+ *
9
+ * ```ts
10
+ * const metric = new ScalarMetric({ name: 'render', format: { style: 'unit', unit: 'millisecond' } });
11
+ * metric.time();
12
+ * doWork();
13
+ * metric.timeEnd(); // records the elapsed milliseconds
14
+ * ```
15
+ */
16
+ export declare class ScalarMetric extends Metric {
17
+ readonly kind: MetricKind;
18
+ private readonly pending;
19
+ /** Starts a timer. Pass a `label` to time a sub-series; it maps to `record`'s `id`. */
20
+ time(label?: string): void;
21
+ /** Stops the timer started by `time(label)` and records the elapsed milliseconds. */
22
+ timeEnd(label?: string): void;
23
+ }
@@ -0,0 +1,42 @@
1
+ import { Metric } from "./Metric.mjs";
2
+ /**
3
+ * A continuous measurement (timings, sizes, …). Samples are aggregated with mean ± standard
4
+ * deviation and IQR outlier removal, and compared against a baseline with a relative noise band.
5
+ *
6
+ * It offers a `console.time`-style timing helper:
7
+ *
8
+ * ```ts
9
+ * const metric = new ScalarMetric({ name: 'render', format: { style: 'unit', unit: 'millisecond' } });
10
+ * metric.time();
11
+ * doWork();
12
+ * metric.timeEnd(); // records the elapsed milliseconds
13
+ * ```
14
+ */
15
+ export class ScalarMetric extends Metric {
16
+ kind = 'scalar';
17
+ pending = new Map();
18
+
19
+ /** Starts a timer. Pass a `label` to time a sub-series; it maps to `record`'s `id`. */
20
+ time(label) {
21
+ const key = label ?? '';
22
+ if (this.pending.has(key)) {
23
+ throw new Error(`${this.name}.time(${label ? `"${label}"` : ''}) was called while a timer is already running for that label.`);
24
+ }
25
+ this.pending.set(key, performance.now());
26
+ }
27
+
28
+ /** Stops the timer started by `time(label)` and records the elapsed milliseconds. */
29
+ timeEnd(label) {
30
+ // Capture the end time first so the map lookup/delete below isn't part of the measurement.
31
+ const end = performance.now();
32
+ const key = label ?? '';
33
+ const start = this.pending.get(key);
34
+ if (start === undefined) {
35
+ throw new Error(`${this.name}.timeEnd(${label ? `"${label}"` : ''}) was called without a matching time().`);
36
+ }
37
+ this.pending.delete(key);
38
+ this.record(end - start, label !== undefined ? {
39
+ id: label
40
+ } : undefined);
41
+ }
42
+ }
package/ciReport.d.mts CHANGED
@@ -58,6 +58,21 @@ declare const benchmarkReportSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
58
58
  outliers: z.ZodNumber;
59
59
  }, z.core.$strip>>;
60
60
  }, z.core.$strip>>;
61
+ declare const metricDefinitionSchema: z.ZodObject<{
62
+ kind: z.ZodEnum<{
63
+ discrete: "discrete";
64
+ scalar: "scalar";
65
+ }>;
66
+ format: z.ZodOptional<z.ZodCustom<Intl.NumberFormatOptions, Intl.NumberFormatOptions>>;
67
+ alarm: z.ZodOptional<z.ZodObject<{
68
+ direction: z.ZodOptional<z.ZodEnum<{
69
+ higherIsBetter: "higherIsBetter";
70
+ lowerIsBetter: "lowerIsBetter";
71
+ }>>;
72
+ warn: z.ZodOptional<z.ZodNumber>;
73
+ error: z.ZodOptional<z.ZodNumber>;
74
+ }, z.core.$strip>>;
75
+ }, z.core.$strip>;
61
76
  declare const benchmarkBaseUploadSchema: z.ZodObject<{
62
77
  version: z.ZodLiteral<number>;
63
78
  timestamp: z.ZodNumber;
@@ -67,6 +82,21 @@ declare const benchmarkBaseUploadSchema: z.ZodObject<{
67
82
  prNumber: z.ZodOptional<z.ZodNumber>;
68
83
  branch: z.ZodString;
69
84
  report: z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
85
+ metricDefinitions: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
86
+ kind: z.ZodEnum<{
87
+ discrete: "discrete";
88
+ scalar: "scalar";
89
+ }>;
90
+ format: z.ZodOptional<z.ZodCustom<Intl.NumberFormatOptions, Intl.NumberFormatOptions>>;
91
+ alarm: z.ZodOptional<z.ZodObject<{
92
+ direction: z.ZodOptional<z.ZodEnum<{
93
+ higherIsBetter: "higherIsBetter";
94
+ lowerIsBetter: "lowerIsBetter";
95
+ }>>;
96
+ warn: z.ZodOptional<z.ZodNumber>;
97
+ error: z.ZodOptional<z.ZodNumber>;
98
+ }, z.core.$strip>>;
99
+ }, z.core.$strip>>>;
70
100
  }, z.core.$strip>;
71
101
  export declare const benchmarkUploadSchema: z.ZodObject<{
72
102
  version: z.ZodLiteral<number>;
@@ -77,6 +107,21 @@ export declare const benchmarkUploadSchema: z.ZodObject<{
77
107
  prNumber: z.ZodOptional<z.ZodNumber>;
78
108
  branch: z.ZodString;
79
109
  report: z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
110
+ metricDefinitions: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
111
+ kind: z.ZodEnum<{
112
+ discrete: "discrete";
113
+ scalar: "scalar";
114
+ }>;
115
+ format: z.ZodOptional<z.ZodCustom<Intl.NumberFormatOptions, Intl.NumberFormatOptions>>;
116
+ alarm: z.ZodOptional<z.ZodObject<{
117
+ direction: z.ZodOptional<z.ZodEnum<{
118
+ higherIsBetter: "higherIsBetter";
119
+ lowerIsBetter: "lowerIsBetter";
120
+ }>>;
121
+ warn: z.ZodOptional<z.ZodNumber>;
122
+ error: z.ZodOptional<z.ZodNumber>;
123
+ }, z.core.$strip>>;
124
+ }, z.core.$strip>>>;
80
125
  base: z.ZodOptional<z.ZodObject<{
81
126
  version: z.ZodLiteral<number>;
82
127
  timestamp: z.ZodNumber;
@@ -86,10 +131,26 @@ export declare const benchmarkUploadSchema: z.ZodObject<{
86
131
  prNumber: z.ZodOptional<z.ZodNumber>;
87
132
  branch: z.ZodString;
88
133
  report: z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
134
+ metricDefinitions: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
135
+ kind: z.ZodEnum<{
136
+ discrete: "discrete";
137
+ scalar: "scalar";
138
+ }>;
139
+ format: z.ZodOptional<z.ZodCustom<Intl.NumberFormatOptions, Intl.NumberFormatOptions>>;
140
+ alarm: z.ZodOptional<z.ZodObject<{
141
+ direction: z.ZodOptional<z.ZodEnum<{
142
+ higherIsBetter: "higherIsBetter";
143
+ lowerIsBetter: "lowerIsBetter";
144
+ }>>;
145
+ warn: z.ZodOptional<z.ZodNumber>;
146
+ error: z.ZodOptional<z.ZodNumber>;
147
+ }, z.core.$strip>>;
148
+ }, z.core.$strip>>>;
89
149
  }, z.core.$strip>>;
90
150
  }, z.core.$strip>;
91
151
  export type RenderStats = z.infer<typeof renderStatsSchema>;
92
152
  export type MetricStats = z.infer<typeof metricStatsSchema>;
153
+ export type BenchmarkMetricDefinition = z.infer<typeof metricDefinitionSchema>;
93
154
  export type BenchmarkReportEntry = z.infer<typeof benchmarkReportEntrySchema>;
94
155
  export type BenchmarkReport = z.infer<typeof benchmarkReportSchema>;
95
156
  export type BenchmarkBaseUpload = z.infer<typeof benchmarkBaseUploadSchema>;
package/ciReport.mjs CHANGED
@@ -45,7 +45,20 @@ const benchmarkReportEntrySchema = z.object({
45
45
  metrics: z.record(z.string(), metricStatsSchema)
46
46
  });
47
47
  const benchmarkReportSchema = z.record(z.string(), benchmarkReportEntrySchema);
48
- const benchmarkBaseUploadSchema = ciReportUploadSchema('benchmark', 1, benchmarkReportSchema);
48
+ const metricDefinitionSchema = z.object({
49
+ kind: z.enum(['scalar', 'discrete']),
50
+ format: z.custom().optional(),
51
+ alarm: z.object({
52
+ direction: z.enum(['lowerIsBetter', 'higherIsBetter']).optional(),
53
+ warn: z.number().min(0).optional(),
54
+ error: z.number().min(0).optional()
55
+ }).optional()
56
+ });
57
+ const benchmarkBaseUploadSchema = ciReportUploadSchema('benchmark', 1, benchmarkReportSchema).extend({
58
+ // Per-metric config for custom metrics, hoisted to the top level (keyed by metric name) so
59
+ // it is stored once rather than duplicated in every report entry. Optional and additive.
60
+ metricDefinitions: z.record(z.string(), metricDefinitionSchema).optional()
61
+ });
49
62
  export const benchmarkUploadSchema = benchmarkBaseUploadSchema.extend({
50
63
  base: benchmarkBaseUploadSchema.optional()
51
64
  });
package/index.d.mts CHANGED
@@ -1,8 +1,13 @@
1
1
  import * as React from 'react';
2
2
  import type { InteractionContext } from "./types.mjs";
3
+ import { ScalarMetric } from "./ScalarMetric.mjs";
3
4
  import "./taskMetaAugmentation.mjs";
4
- export type { RenderEvent, BenchmarkMetric, IterationData, InteractionContext } from "./types.mjs";
5
+ export type { RenderEvent, IterationData, InteractionContext } from "./types.mjs";
6
+ export type { MetricKind, MetricDirection, MetricAlarm, MetricConfig, MetricDefinition } from "./types.mjs";
5
7
  export { ElementTiming } from "./ElementTiming.mjs";
8
+ export { Metric, type MetricRecordOptions } from "./Metric.mjs";
9
+ export { ScalarMetric };
10
+ export { DiscreteMetric } from "./DiscreteMetric.mjs";
6
11
  declare global {
7
12
  interface Window {
8
13
  gc?: () => void;
@@ -12,5 +17,11 @@ interface BenchmarkOptions {
12
17
  runs?: number;
13
18
  warmupRuns?: number;
14
19
  afterEach?: () => Promise<void> | void;
20
+ /**
21
+ * Start each iteration with React render/paint recording paused. The interaction callback then
22
+ * calls `resumeReactRecording()` at the point it cares about — useful to exclude the mount and
23
+ * measure only the renders/paint of a later interaction. Defaults to `false` (mount recorded).
24
+ */
25
+ reactRecordingPaused?: boolean;
15
26
  }
16
27
  export declare function benchmark(name: string, renderFn: () => React.ReactElement, interactionOrOptions?: ((ctx: InteractionContext) => Promise<void> | void) | BenchmarkOptions, maybeOptions?: BenchmarkOptions): void;