Skip to content

Commit ad63721

Browse files
jorge-cabshwanton
authored andcommitted
[mcp] Add proper web-vitals metric collection
1 parent 66de8e5 commit ad63721

File tree

2 files changed

+118
-70
lines changed

2 files changed

+118
-70
lines changed

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

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ 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 ? (values.reduce((acc, curr) => acc + curr, 0) / values.length) + 'ms' : 'could not collect';
27+
}
28+
2529
const server = new McpServer({
2630
name: 'React',
2731
version: '0.0.0',
@@ -286,7 +290,6 @@ server.tool(
286290
server.tool(
287291
'review-react-runtime',
288292
`Run this tool every time you propose a performance related change to verify if your suggestion actually improves performance.
289-
290293
<requirements>
291294
This tool has some requirements on the code input:
292295
- The react code that is passed into this tool MUST contain an App functional component without arrow function.
@@ -307,12 +310,12 @@ server.tool(
307310
308311
<iterate>
309312
(repeat until every metric is good or two consecutive cycles show no gain)
310-
- Always run the tool once on the original code before any modification
311-
- Run the tool again after making the modification, and apply one focused change based on the failing metric plus React-specific guidance:
313+
- Apply one focused change based on the failing metric plus React-specific guidance:
312314
- LCP: lazy-load off-screen images, inline critical CSS, preconnect, use React.lazy + Suspense for below-the-fold modules. if the user requests for it, use React Server Components for static content (Server Components).
313315
- INP: wrap non-critical updates in useTransition, avoid calling setState inside useEffect.
314316
- CLS: reserve space via explicit width/height or aspect-ratio, keep stable list keys, use fixed-size skeleton loaders, animate only transform/opacity, avoid inserting ads or banners without placeholders.
315-
- Compare the results of your modified code compared to the original to verify that your changes have improved performance.
317+
318+
Stop when every metric is classified as good. Return the final metric table and the list of applied changes.
316319
</iterate>
317320
`,
318321
{
@@ -326,17 +329,17 @@ server.tool(
326329
# React Component Performance Results
327330
328331
## Mean Render Time
329-
${results.renderTime / iterations}ms
332+
${calculateMean(results.renderTime)}
330333
334+
TEST: ${results.webVitals.inp}
331335
## 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
336+
- Cumulative Layout Shift (CLS): ${calculateMean(results.webVitals.cls)}
337+
- Largest Contentful Paint (LCP): ${calculateMean(results.webVitals.lcp)}
338+
- Interaction to Next Paint (INP): ${calculateMean(results.webVitals.inp)}
336339
337340
## Mean React Profiler
338-
- Actual Duration: ${results.reactProfiler.actualDuration / iterations}ms
339-
- Base Duration: ${results.reactProfiler.baseDuration / iterations}ms
341+
- Actual Duration: ${calculateMean(results.reactProfiler.actualDuration)}
342+
- Base Duration: ${calculateMean(results.reactProfiler.baseDuration)}
340343
`;
341344

342345
return {

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

Lines changed: 104 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,52 @@ 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+
31+
type EvaluationResults = {
32+
renderTime: number | null;
33+
webVitals: {
34+
cls: number | null;
35+
lcp: number | null;
36+
inp: number | null;
37+
fid: number | null;
38+
ttfb: number | null;
39+
};
40+
reactProfiler: {
41+
id: number | null;
42+
phase: number | null;
43+
actualDuration: number | null;
44+
baseDuration: number | null;
45+
startTime: number | null;
46+
commitTime: number | null;
47+
};
48+
error: Error | null;
49+
};
50+
51+
function delay(time: number) {
52+
return new Promise(function (resolve) {
53+
setTimeout(resolve, time)
54+
});
55+
}
56+
3057
export async function measurePerformance(
3158
code: string,
3259
iterations: number,
@@ -68,66 +95,84 @@ export async function measurePerformance(
6895

6996
const browser = await puppeteer.launch();
7097
const page = await browser.newPage();
71-
await page.setViewport({width: 1280, height: 720});
98+
await page.setViewport({ width: 1280, height: 720 });
7299
const html = buildHtml(transpiled);
73100

74101
let performanceResults: PerformanceResults = {
75-
renderTime: 0,
102+
renderTime: [],
76103
webVitals: {
77-
cls: 0,
78-
lcp: 0,
79-
inp: 0,
80-
fid: 0,
81-
ttfb: 0,
104+
cls: [],
105+
lcp: [],
106+
inp: [],
107+
fid: [],
108+
ttfb: [],
82109
},
83110
reactProfiler: {
84-
id: 0,
85-
phase: 0,
86-
actualDuration: 0,
87-
baseDuration: 0,
88-
startTime: 0,
89-
commitTime: 0,
111+
id: [],
112+
phase: [],
113+
actualDuration: [],
114+
baseDuration: [],
115+
startTime: [],
116+
commitTime: [],
90117
},
91118
error: null,
92119
};
93120

94121
for (let ii = 0; ii < iterations; ii++) {
95-
await page.setContent(html, {waitUntil: 'networkidle0'});
122+
await page.setContent(html, { waitUntil: 'networkidle0' });
96123
await page.waitForFunction(
97124
'window.__RESULT__ !== undefined && (window.__RESULT__.renderTime !== null || window.__RESULT__.error !== null)',
98125
);
126+
99127
// 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();
128+
const selectors = await page.evaluate(() => {
129+
window.__INTERACTABLE_SELECTORS__ = [];
130+
const elements = Array.from(document.querySelectorAll('a')).concat(Array.from(document.querySelectorAll('button')));
131+
for (const el of elements) {
132+
window.__INTERACTABLE_SELECTORS__.push(el.tagName.toLowerCase());
104133
}
105-
return true;
106-
})() `);
107-
const evaluationResult: PerformanceResults = await page.evaluate(() => {
134+
return window.__INTERACTABLE_SELECTORS__;
135+
});
136+
137+
for (const selector of selectors) {
138+
await page.click(selector);
139+
await delay(500);
140+
}
141+
142+
// Visit a new page for 1s to background the current page so that WebVitals can finish being calculated
143+
const tempPage = await browser.newPage();
144+
await tempPage.evaluate(() => {
145+
return new Promise(resolve => {
146+
setTimeout(() => {
147+
resolve(true);
148+
}, 1000);
149+
});
150+
});
151+
await tempPage.close();
152+
153+
const evaluationResult: EvaluationResults = await page.evaluate(() => {
108154
return (window as any).__RESULT__;
109155
});
110156

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;
157+
if (evaluationResult.renderTime !== null) {
158+
performanceResults.renderTime.push(evaluationResult.renderTime);
159+
}
160+
161+
const webVitalMetrics = ['cls', 'lcp', 'inp', 'fid', 'ttfb'] as const;
162+
for (const metric of webVitalMetrics) {
163+
if (evaluationResult.webVitals[metric] !== null) {
164+
performanceResults.webVitals[metric].push(evaluationResult.webVitals[metric]);
165+
}
166+
}
167+
168+
const profilerMetrics = ['id', 'phase', 'actualDuration', 'baseDuration', 'startTime', 'commitTime'] as const;
169+
for (const metric of profilerMetrics) {
170+
if (evaluationResult.reactProfiler[metric] !== null) {
171+
performanceResults.reactProfiler[metric].push(
172+
evaluationResult.reactProfiler[metric]
173+
);
174+
}
175+
}
131176

132177
performanceResults.error = evaluationResult.error;
133178
}
@@ -159,14 +204,14 @@ function buildHtml(transpiled: string) {
159204
renderTime: null,
160205
webVitals: {},
161206
reactProfiler: {},
162-
error: null
207+
error: null,
163208
};
164209
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; });
210+
webVitals.onCLS(({value}) => { window.__RESULT__.webVitals.cls = value; });
211+
webVitals.onLCP(({value}) => { window.__RESULT__.webVitals.lcp = value; });
212+
webVitals.onINP(({value}) => { window.__RESULT__.webVitals.inp = value; });
213+
webVitals.onFID(({value}) => { window.__RESULT__.webVitals.fid = value; });
214+
webVitals.onTTFB(({value}) => { window.__RESULT__.webVitals.ttfb = value; });
170215
171216
try {
172217
${transpiled}

0 commit comments

Comments
 (0)