Skip to content

Commit 28562dd

Browse files
committed
[mcp] Add proper web-vitals metric collection
1 parent 3cc6c58 commit 28562dd

File tree

2 files changed

+270
-1
lines changed

2 files changed

+270
-1
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import * as cheerio from 'cheerio';
2020
import { queryAlgolia } from './utils/algolia';
2121
import assertExhaustive from './utils/assertExhaustive';
2222
import { convert } from 'html-to-text';
23-
import { measurePerformance } from './tools/runtimePerf';
23+
import { measurePerformance } from './utils/runtimePerf';
2424

2525
function calculateMean(values: number[]): string {
2626
return values.length > 0 ? (values.reduce((acc, curr) => acc + curr, 0) / values.length) + 'ms' : 'could not collect';
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import * as babel from '@babel/core';
2+
import puppeteer from 'puppeteer';
3+
4+
type PerformanceResults = {
5+
renderTime: number[];
6+
webVitals: {
7+
cls: number[];
8+
lcp: number[];
9+
inp: number[];
10+
fid: number[];
11+
ttfb: number[];
12+
};
13+
reactProfiler: {
14+
id: number[];
15+
phase: number[];
16+
actualDuration: number[];
17+
baseDuration: number[];
18+
startTime: number[];
19+
commitTime: number[];
20+
};
21+
error: Error | null;
22+
};
23+
24+
25+
type EvaluationResults = {
26+
renderTime: number | null;
27+
webVitals: {
28+
cls: number | null;
29+
lcp: number | null;
30+
inp: number | null;
31+
fid: number | null;
32+
ttfb: number | null;
33+
};
34+
reactProfiler: {
35+
id: number | null;
36+
phase: number | null;
37+
actualDuration: number | null;
38+
baseDuration: number | null;
39+
startTime: number | null;
40+
commitTime: number | null;
41+
};
42+
error: Error | null;
43+
};
44+
45+
function delay(time: number) {
46+
return new Promise(function (resolve) {
47+
setTimeout(resolve, time)
48+
});
49+
}
50+
51+
export async function measurePerformance(
52+
code: string,
53+
iterations: number,
54+
): Promise<PerformanceResults> {
55+
const babelOptions = {
56+
configFile: false,
57+
babelrc: false,
58+
presets: [
59+
require.resolve('@babel/preset-env'),
60+
require.resolve('@babel/preset-react'),
61+
],
62+
};
63+
64+
// Parse the code to AST
65+
const parsed = await babel.parseAsync(code, babelOptions);
66+
if (!parsed) {
67+
throw new Error('Failed to parse code');
68+
}
69+
70+
// Transform AST to browser-compatible JavaScript
71+
const transformResult = await babel.transformFromAstAsync(parsed, undefined, {
72+
...babelOptions,
73+
filename: 'file.jsx',
74+
plugins: [
75+
() => ({
76+
visitor: {
77+
ImportDeclaration(
78+
path: babel.NodePath<babel.types.ImportDeclaration>,
79+
) {
80+
const value = path.node.source.value;
81+
if (value === 'react' || value === 'react-dom') {
82+
path.remove();
83+
}
84+
},
85+
},
86+
}),
87+
],
88+
});
89+
90+
const transpiled = transformResult?.code || undefined;
91+
if (!transpiled) {
92+
throw new Error('Failed to transpile code');
93+
}
94+
95+
const browser = await puppeteer.launch();
96+
97+
const page = await browser.newPage();
98+
await page.setViewport({ width: 1280, height: 720 });
99+
const html = buildHtml(transpiled);
100+
await page.setContent(html, {waitUntil: 'networkidle0'});
101+
102+
let performanceResults: PerformanceResults = {
103+
renderTime: [],
104+
webVitals: {
105+
cls: [],
106+
lcp: [],
107+
inp: [],
108+
fid: [],
109+
ttfb: [],
110+
},
111+
reactProfiler: {
112+
id: [],
113+
phase: [],
114+
actualDuration: [],
115+
baseDuration: [],
116+
startTime: [],
117+
commitTime: [],
118+
},
119+
error: null,
120+
};
121+
122+
for (let ii = 0; ii < iterations; ii++) {
123+
await page.setContent(html, { waitUntil: 'networkidle0' });
124+
await page.waitForFunction(
125+
'window.__RESULT__ !== undefined && (window.__RESULT__.renderTime !== null || window.__RESULT__.error !== null)',
126+
);
127+
128+
// ui chaos monkey
129+
const selectors = await page.evaluate(() => {
130+
window.__INTERACTABLE_SELECTORS__ = [];
131+
const elements = Array.from(document.querySelectorAll('a')).concat(Array.from(document.querySelectorAll('button')));
132+
for (const el of elements) {
133+
window.__INTERACTABLE_SELECTORS__.push(el.tagName.toLowerCase());
134+
}
135+
return window.__INTERACTABLE_SELECTORS__;
136+
});
137+
138+
for (const selector of selectors) {
139+
await page.click(selector);
140+
await delay(500);
141+
}
142+
143+
// Visit a new page for 1s to background the current page so that WebVitals can finish being calculated
144+
const tempPage = await browser.newPage();
145+
await tempPage.evaluate(() => {
146+
return new Promise(resolve => {
147+
setTimeout(() => {
148+
resolve(true);
149+
}, 1000);
150+
});
151+
});
152+
await tempPage.close();
153+
154+
const evaluationResult: EvaluationResults = await page.evaluate(() => {
155+
return (window as any).__RESULT__;
156+
});
157+
158+
if (evaluationResult.renderTime !== null) {
159+
performanceResults.renderTime.push(evaluationResult.renderTime);
160+
}
161+
162+
const webVitalMetrics = ['cls', 'lcp', 'inp', 'fid', 'ttfb'] as const;
163+
for (const metric of webVitalMetrics) {
164+
if (evaluationResult.webVitals[metric] !== null) {
165+
performanceResults.webVitals[metric].push(evaluationResult.webVitals[metric]);
166+
}
167+
}
168+
169+
const profilerMetrics = ['id', 'phase', 'actualDuration', 'baseDuration', 'startTime', 'commitTime'] as const;
170+
for (const metric of profilerMetrics) {
171+
if (evaluationResult.reactProfiler[metric] !== null) {
172+
performanceResults.reactProfiler[metric].push(
173+
evaluationResult.reactProfiler[metric]
174+
);
175+
}
176+
}
177+
178+
performanceResults.error = evaluationResult.error;
179+
}
180+
181+
await browser.close();
182+
return result;
183+
}
184+
185+
function buildHtml(transpiled: string) {
186+
const html = `
187+
<!DOCTYPE html>
188+
<html>
189+
<head>
190+
<meta charset="UTF-8">
191+
<title>React Performance Test</title>
192+
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
193+
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
194+
<script src="https://unpkg.com/[email protected]/dist/web-vitals.iife.js"></script>
195+
<style>
196+
body { margin: 0; }
197+
#root { padding: 20px; }
198+
</style>
199+
</head>
200+
<body>
201+
<div id="root"></div>
202+
<script>
203+
window.__RESULT__ = {
204+
renderTime: null,
205+
webVitals: {},
206+
reactProfiler: {},
207+
error: null,
208+
};
209+
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; });
215+
216+
try {
217+
${transpiled}
218+
219+
window.App = App;
220+
221+
// Render the component to the DOM with profiling
222+
const AppComponent = window.App || (() => React.createElement('div', null, 'No App component exported'));
223+
224+
const root = ReactDOM.createRoot(document.getElementById('root'), {
225+
onUncaughtError: (error, errorInfo) => {
226+
window.__RESULT__.error = error;
227+
}
228+
});
229+
230+
const renderStart = performance.now()
231+
232+
root.render(
233+
React.createElement(React.Profiler, {
234+
id: 'App',
235+
onRender: (id, phase, actualDuration, baseDuration, startTime, commitTime) => {
236+
window.__RESULT__.reactProfiler.id = id;
237+
window.__RESULT__.reactProfiler.phase = phase;
238+
window.__RESULT__.reactProfiler.actualDuration = actualDuration;
239+
window.__RESULT__.reactProfiler.baseDuration = baseDuration;
240+
window.__RESULT__.reactProfiler.startTime = startTime;
241+
window.__RESULT__.reactProfiler.commitTime = commitTime;
242+
}
243+
}, React.createElement(AppComponent))
244+
);
245+
246+
const renderEnd = performance.now();
247+
248+
window.__RESULT__.renderTime = renderEnd - renderStart;
249+
} catch (error) {
250+
console.error('Error rendering component:', error);
251+
window.__RESULT__.error = error;
252+
}
253+
</script>
254+
<script>
255+
window.onerror = function(message, url, lineNumber) {
256+
const formattedMessage = message + '@' + lineNumber;
257+
if (window.__RESULT__.error && window.__RESULT__.error.message != null) {
258+
window.__RESULT__.error = window.__RESULT__.error + '\n\n' + formattedMessage;
259+
} else {
260+
window.__RESULT__.error = message + formattedMessage;
261+
}
262+
};
263+
</script>
264+
</body>
265+
</html>
266+
`;
267+
268+
return html;
269+
}

0 commit comments

Comments
 (0)