[mcp] Add proper web-vitals metric collection (#33109)
Multiple things here: - Improve the mean calculation for metrics so we don't report 0 when web-vitals fail to be retrieved - improve ui chaos monkey to use puppeteer APIs since only those trigger INP/CLS metrics since we need emulated mouse clicks - Add logic to navigate to a temp page after render since some web-vitals metrics are only calculated when the page is backgrounded - Some readability improvements
Jorge Cabiedes committed
May 6, 2025 at 08:50 UTC
7a2c7045aed222b1ece44a18db6326f2f10c89e3
2 files changed
+130
-64
compiler/packages/react-mcp-server/src/index.ts
+12
-7
@@ -22,6 +22,12 @@ import assertExhaustive from './utils/assertExhaustive';
22
import {convert} from 'html-to-text';
23
import {measurePerformance} from './tools/runtimePerf';
24
25
+function calculateMean(values: number[]): string {
26
+ return values.length > 0
27
+ ? values.reduce((acc, curr) => acc + curr, 0) / values.length + 'ms'
28
+ : 'could not collect';
29
+}
30
+
31
const server = new McpServer({
32
name: 'React',
33
version: '0.0.0',
@@ -326,17 +332,16 @@ server.tool(
332
# React Component Performance Results
333
334
## Mean Render Time
329
-${results.renderTime / iterations}ms
335
+${calculateMean(results.renderTime)}
336
337
## Mean Web Vitals
332
-- Cumulative Layout Shift (CLS): ${results.webVitals.cls / iterations}ms
333
-- Largest Contentful Paint (LCP): ${results.webVitals.lcp / iterations}ms
334
-- Interaction to Next Paint (INP): ${results.webVitals.inp / iterations}ms
335
-- First Input Delay (FID): ${results.webVitals.fid / iterations}ms
338
+- Cumulative Layout Shift (CLS): ${calculateMean(results.webVitals.cls)}
339
+- Largest Contentful Paint (LCP): ${calculateMean(results.webVitals.lcp)}
340
+- Interaction to Next Paint (INP): ${calculateMean(results.webVitals.inp)}
341
342
## Mean React Profiler
338
-- Actual Duration: ${results.reactProfiler.actualDuration / iterations}ms
339
-- Base Duration: ${results.reactProfiler.baseDuration / iterations}ms
343
+- Actual Duration: ${calculateMean(results.reactProfiler.actualDuration)}
344
+- Base Duration: ${calculateMean(results.reactProfiler.baseDuration)}
345
`;
346
347
return {
compiler/packages/react-mcp-server/src/tools/runtimePerf.ts
+118
-57
@@ -8,25 +8,51 @@ import * as babelPresetEnv from '@babel/preset-env';
8
import * as babelPresetReact from '@babel/preset-react';
9
10
type PerformanceResults = {
11
- renderTime: number;
11
+ renderTime: number[];
12
webVitals: {
13
- cls: number;
14
- lcp: number;
15
- inp: number;
16
- fid: number;
17
- ttfb: number;
13
+ cls: number[];
14
+ lcp: number[];
15
+ inp: number[];
16
+ fid: number[];
17
+ ttfb: number[];
18
};
19
reactProfiler: {
20
- id: number;
21
- phase: number;
22
- actualDuration: number;
23
- baseDuration: number;
24
- startTime: number;
25
- commitTime: number;
20
+ id: number[];
21
+ phase: number[];
22
+ actualDuration: number[];
23
+ baseDuration: number[];
24
+ startTime: number[];
25
+ commitTime: number[];
26
};
27
error: Error | null;
28
};
29
30
+type EvaluationResults = {
31
+ renderTime: number | null;
32
+ webVitals: {
33
+ cls: number | null;
34
+ lcp: number | null;
35
+ inp: number | null;
36
+ fid: number | null;
37
+ ttfb: number | null;
38
+ };
39
+ reactProfiler: {
40
+ id: number | null;
41
+ phase: number | null;
42
+ actualDuration: number | null;
43
+ baseDuration: number | null;
44
+ startTime: number | null;
45
+ commitTime: number | null;
46
+ };
47
+ error: Error | null;
48
+};
49
+
50
+function delay(time: number) {
51
+ return new Promise(function (resolve) {
52
+ setTimeout(resolve, time);
53
+ });
54
+}
55
+
56
export async function measurePerformance(
57
code: string,
58
iterations: number,
@@ -72,21 +98,21 @@ export async function measurePerformance(
98
const html = buildHtml(transpiled);
99
100
let performanceResults: PerformanceResults = {
75
- renderTime: 0,
101
+ renderTime: [],
102
webVitals: {
77
- cls: 0,
78
- lcp: 0,
79
- inp: 0,
80
- fid: 0,
81
- ttfb: 0,
103
+ cls: [],
104
+ lcp: [],
105
+ inp: [],
106
+ fid: [],
107
+ ttfb: [],
108
},
109
reactProfiler: {
84
- id: 0,
85
- phase: 0,
86
- actualDuration: 0,
87
- baseDuration: 0,
88
- startTime: 0,
89
- commitTime: 0,
110
+ id: [],
111
+ phase: [],
112
+ actualDuration: [],
113
+ baseDuration: [],
114
+ startTime: [],
115
+ commitTime: [],
116
},
117
error: null,
118
};
@@ -96,38 +122,73 @@ export async function measurePerformance(
122
await page.waitForFunction(
123
'window.__RESULT__ !== undefined && (window.__RESULT__.renderTime !== null || window.__RESULT__.error !== null)',
124
);
125
+
126
// ui chaos monkey
100
- await page.waitForFunction(`window.__RESULT__ !== undefined && (function() {
101
- for (const el of [...document.querySelectorAll('a'), ...document.querySelectorAll('button')]) {
102
- console.log(el);
103
- el.click();
127
+ const selectors = await page.evaluate(() => {
128
+ window.__INTERACTABLE_SELECTORS__ = [];
129
+ const elements = Array.from(document.querySelectorAll('a')).concat(
130
+ Array.from(document.querySelectorAll('button')),
131
+ );
132
+ for (const el of elements) {
133
+ window.__INTERACTABLE_SELECTORS__.push(el.tagName.toLowerCase());
134
}
105
- return true;
106
- })() `);
107
- const evaluationResult: PerformanceResults = await page.evaluate(() => {
135
+ return window.__INTERACTABLE_SELECTORS__;
136
+ });
137
+
138
+ await Promise.all(
139
+ selectors.map(async (selector: string) => {
140
+ try {
141
+ await page.click(selector);
142
+ } catch (e) {
143
+ console.log(`warning: Could not click ${selector}: ${e.message}`);
144
+ }
145
+ }),
146
+ );
147
+ await delay(500);
148
+
149
+ // Visit a new page for 1s to background the current page so that WebVitals can finish being calculated
150
+ const tempPage = await browser.newPage();
151
+ await tempPage.evaluate(() => {
152
+ return new Promise(resolve => {
153
+ setTimeout(() => {
154
+ resolve(true);
155
+ }, 1000);
156
+ });
157
+ });
158
+ await tempPage.close();
159
+
160
+ const evaluationResult: EvaluationResults = await page.evaluate(() => {
161
return (window as any).__RESULT__;
162
});
163
111
- // TODO: investigate why webvital metrics are not populating correctly
112
- performanceResults.renderTime += evaluationResult.renderTime;
113
- performanceResults.webVitals.cls += evaluationResult.webVitals.cls || 0;
114
- performanceResults.webVitals.lcp += evaluationResult.webVitals.lcp || 0;
115
- performanceResults.webVitals.inp += evaluationResult.webVitals.inp || 0;
116
- performanceResults.webVitals.fid += evaluationResult.webVitals.fid || 0;
117
- performanceResults.webVitals.ttfb += evaluationResult.webVitals.ttfb || 0;
118
-
119
- performanceResults.reactProfiler.id +=
120
- evaluationResult.reactProfiler.actualDuration || 0;
121
- performanceResults.reactProfiler.phase +=
122
- evaluationResult.reactProfiler.phase || 0;
123
- performanceResults.reactProfiler.actualDuration +=
124
- evaluationResult.reactProfiler.actualDuration || 0;
125
- performanceResults.reactProfiler.baseDuration +=
126
- evaluationResult.reactProfiler.baseDuration || 0;
127
- performanceResults.reactProfiler.startTime +=
128
- evaluationResult.reactProfiler.startTime || 0;
129
- performanceResults.reactProfiler.commitTime +=
130
- evaluationResult.reactProfiler.commitTime || 0;
164
+ if (evaluationResult.renderTime !== null) {
165
+ performanceResults.renderTime.push(evaluationResult.renderTime);
166
+ }
167
+
168
+ const webVitalMetrics = ['cls', 'lcp', 'inp', 'fid', 'ttfb'] as const;
169
+ for (const metric of webVitalMetrics) {
170
+ if (evaluationResult.webVitals[metric] !== null) {
171
+ performanceResults.webVitals[metric].push(
172
+ evaluationResult.webVitals[metric],
173
+ );
174
+ }
175
+ }
176
+
177
+ const profilerMetrics = [
178
+ 'id',
179
+ 'phase',
180
+ 'actualDuration',
181
+ 'baseDuration',
182
+ 'startTime',
183
+ 'commitTime',
184
+ ] as const;
185
+ for (const metric of profilerMetrics) {
186
+ if (evaluationResult.reactProfiler[metric] !== null) {
187
+ performanceResults.reactProfiler[metric].push(
188
+ evaluationResult.reactProfiler[metric],
189
+ );
190
+ }
191
+ }
192
193
performanceResults.error = evaluationResult.error;
194
}
@@ -159,14 +220,14 @@ function buildHtml(transpiled: string) {
220
renderTime: null,
221
webVitals: {},
222
reactProfiler: {},
162
- error: null
223
+ error: null,
224
};
225
165
- webVitals.onCLS((metric) => { window.__RESULT__.webVitals.cls = metric; });
166
- webVitals.onLCP((metric) => { window.__RESULT__.webVitals.lcp = metric; });
167
- webVitals.onINP((metric) => { window.__RESULT__.webVitals.inp = metric; });
168
- webVitals.onFID((metric) => { window.__RESULT__.webVitals.fid = metric; });
169
- webVitals.onTTFB((metric) => { window.__RESULT__.webVitals.ttfb = metric; });
226
+ webVitals.onCLS(({value}) => { window.__RESULT__.webVitals.cls = value; });
227
+ webVitals.onLCP(({value}) => { window.__RESULT__.webVitals.lcp = value; });
228
+ webVitals.onINP(({value}) => { window.__RESULT__.webVitals.inp = value; });
229
+ webVitals.onFID(({value}) => { window.__RESULT__.webVitals.fid = value; });
230
+ webVitals.onTTFB(({value}) => { window.__RESULT__.webVitals.ttfb = value; });
231
232
try {
233
${transpiled}