Skip to content

Commit 7a2c704

Browse files
authored
[mcp] Add proper web-vitals metric collection (facebook#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
1 parent 845d937 commit 7a2c704

File tree

2 files changed

+130
-64
lines changed

2 files changed

+130
-64
lines changed

compiler/packages/react-mcp-server/src/index.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ import assertExhaustive from './utils/assertExhaustive';
2222
import {convert} from 'html-to-text';
2323
import {measurePerformance} from './tools/runtimePerf';
2424

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+
2531
const server = new McpServer({
2632
name: 'React',
2733
version: '0.0.0',
@@ -326,17 +332,16 @@ server.tool(
326332
# React Component Performance Results
327333
328334
## Mean Render Time
329-
${results.renderTime / iterations}ms
335+
${calculateMean(results.renderTime)}
330336
331337
## 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)}
336341
337342
## 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)}
340345
`;
341346

342347
return {

compiler/packages/react-mcp-server/src/tools/runtimePerf.ts

Lines changed: 118 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,51 @@ import * as babelPresetEnv from '@babel/preset-env';
88
import * as babelPresetReact from '@babel/preset-react';
99

1010
type PerformanceResults = {
11-
renderTime: number;
11+
renderTime: number[];
1212
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[];
1818
};
1919
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[];
2626
};
2727
error: Error | null;
2828
};
2929

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+
3056
export async function measurePerformance(
3157
code: string,
3258
iterations: number,
@@ -72,21 +98,21 @@ export async function measurePerformance(
7298
const html = buildHtml(transpiled);
7399

74100
let performanceResults: PerformanceResults = {
75-
renderTime: 0,
101+
renderTime: [],
76102
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: [],
82108
},
83109
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: [],
90116
},
91117
error: null,
92118
};
@@ -96,38 +122,73 @@ export async function measurePerformance(
96122
await page.waitForFunction(
97123
'window.__RESULT__ !== undefined && (window.__RESULT__.renderTime !== null || window.__RESULT__.error !== null)',
98124
);
125+
99126
// 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());
104134
}
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(() => {
108161
return (window as any).__RESULT__;
109162
});
110163

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+
}
131192

132193
performanceResults.error = evaluationResult.error;
133194
}
@@ -159,14 +220,14 @@ function buildHtml(transpiled: string) {
159220
renderTime: null,
160221
webVitals: {},
161222
reactProfiler: {},
162-
error: null
223+
error: null,
163224
};
164225
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; });
170231
171232
try {
172233
${transpiled}

0 commit comments

Comments
 (0)