Repository: facebook/react
Pull request: [mcp] Add proper web-vitals metric collection

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

Changed files:
filename: compiler/packages/react-mcp-server/src/index.ts
patch: @@ -22,6 +22,12 @@ import assertExhaustive from './utils/assertExhaustive';
 import {convert} from 'html-to-text';
 import {measurePerformance} from './tools/runtimePerf';
 
+function calculateMean(values: number[]): string {
+  return values.length > 0
+    ? values.reduce((acc, curr) => acc + curr, 0) / values.length + 'ms'
+    : 'could not collect';
+}
+
 const server = new McpServer({
   name: 'React',
   version: '0.0.0',
@@ -326,17 +332,16 @@ server.tool(
 # React Component Performance Results
 
 ## Mean Render Time
-${results.renderTime / iterations}ms
+${calculateMean(results.renderTime)}
 
 ## Mean Web Vitals
-- Cumulative Layout Shift (CLS): ${results.webVitals.cls / iterations}ms
-- Largest Contentful Paint (LCP): ${results.webVitals.lcp / iterations}ms
-- Interaction to Next Paint (INP): ${results.webVitals.inp / iterations}ms
-- First Input Delay (FID): ${results.webVitals.fid / iterations}ms
+- Cumulative Layout Shift (CLS): ${calculateMean(results.webVitals.cls)}
+- Largest Contentful Paint (LCP): ${calculateMean(results.webVitals.lcp)}
+- Interaction to Next Paint (INP): ${calculateMean(results.webVitals.inp)}
 
 ## Mean React Profiler
-- Actual Duration: ${results.reactProfiler.actualDuration / iterations}ms
-- Base Duration: ${results.reactProfiler.baseDuration / iterations}ms
+- Actual Duration: ${calculateMean(results.reactProfiler.actualDuration)}
+- Base Duration: ${calculateMean(results.reactProfiler.baseDuration)}
 `;
 
       return {

filename: compiler/packages/react-mcp-server/src/tools/runtimePerf.ts
patch: @@ -8,25 +8,51 @@ import * as babelPresetEnv from '@babel/preset-env';
 import * as babelPresetReact from '@babel/preset-react';
 
 type PerformanceResults = {
-  renderTime: number;
+  renderTime: number[];
   webVitals: {
-    cls: number;
-    lcp: number;
-    inp: number;
-    fid: number;
-    ttfb: number;
+    cls: number[];
+    lcp: number[];
+    inp: number[];
+    fid: number[];
+    ttfb: number[];
   };
   reactProfiler: {
-    id: number;
-    phase: number;
-    actualDuration: number;
-    baseDuration: number;
-    startTime: number;
-    commitTime: number;
+    id: number[];
+    phase: number[];
+    actualDuration: number[];
+    baseDuration: number[];
+    startTime: number[];
+    commitTime: number[];
   };
   error: Error | null;
 };
 
+type EvaluationResults = {
+  renderTime: number | null;
+  webVitals: {
+    cls: number | null;
+    lcp: number | null;
+    inp: number | null;
+    fid: number | null;
+    ttfb: number | null;
+  };
+  reactProfiler: {
+    id: number | null;
+    phase: number | null;
+    actualDuration: number | null;
+    baseDuration: number | null;
+    startTime: number | null;
+    commitTime: number | null;
+  };
+  error: Error | null;
+};
+
+function delay(time: number) {
+  return new Promise(function (resolve) {
+    setTimeout(resolve, time);
+  });
+}
+
 export async function measurePerformance(
   code: string,
   iterations: number,
@@ -72,21 +98,21 @@ export async function measurePerformance(
   const html = buildHtml(transpiled);
 
   let performanceResults: PerformanceResults = {
-    renderTime: 0,
+    renderTime: [],
     webVitals: {
-      cls: 0,
-      lcp: 0,
-      inp: 0,
-      fid: 0,
-      ttfb: 0,
+      cls: [],
+      lcp: [],
+      inp: [],
+      fid: [],
+      ttfb: [],
     },
     reactProfiler: {
-      id: 0,
-      phase: 0,
-      actualDuration: 0,
-      baseDuration: 0,
-      startTime: 0,
-      commitTime: 0,
+      id: [],
+      phase: [],
+      actualDuration: [],
+      baseDuration: [],
+      startTime: [],
+      commitTime: [],
     },
     error: null,
   };
@@ -96,38 +122,73 @@ export async function measurePerformance(
     await page.waitForFunction(
       'window.__RESULT__ !== undefined && (window.__RESULT__.renderTime !== null || window.__RESULT__.error !== null)',
     );
+
     // ui chaos monkey
-    await page.waitForFunction(`window.__RESULT__ !== undefined && (function() {
-      for (const el of [...document.querySelectorAll('a'), ...document.querySelectorAll('button')]) {
-        console.log(el);
-        el.click();
+    const selectors = await page.evaluate(() => {
+      window.__INTERACTABLE_SELECTORS__ = [];
+      const elements = Array.from(document.querySelectorAll('a')).concat(
+        Array.from(document.querySelectorAll('button')),
+      );
+      for (const el of elements) {
+        window.__INTERACTABLE_SELECTORS__.push(el.tagName.toLowerCase());
       }
-      return true;
-    })() `);
-    const evaluationResult: PerformanceResults = await page.evaluate(() => {
+      return window.__INTERACTABLE_SELECTORS__;
+    });
+
+    await Promise.all(
+      selectors.map(async (selector: string) => {
+        try {
+          await page.click(selector);
+        } catch (e) {
+          console.log(`warning: Could not click ${selector}: ${e.message}`);
+        }
+      }),
+    );
+    await delay(500);
+
+    // Visit a new page for 1s to background the current page so that WebVitals can finish being calculated
+    const tempPage = await browser.newPage();
+    await tempPage.evaluate(() => {
+      return new Promise(resolve => {
+        setTimeout(() => {
+          resolve(true);
+        }, 1000);
+      });
+    });
+    await tempPage.close();
+
+    const evaluationResult: EvaluationResults = await page.evaluate(() => {
       return (window as any).__RESULT__;
     });
 
-    // TODO: investigate why webvital metrics are not populating correctly
-    performanceResults.renderTime += evaluationResult.renderTime;
-    performanceResults.webVitals.cls += evaluationResult.webVitals.cls || 0;
-    performanceResults.webVitals.lcp += evaluationResult.webVitals.lcp || 0;
-    performanceResults.webVitals.inp += evaluationResult.webVitals.inp || 0;
-    performanceResults.webVitals.fid += evaluationResult.webVitals.fid || 0;
-    performanceResults.webVitals.ttfb += evaluationResult.webVitals.ttfb || 0;
-
-    performanceResults.reactProfiler.id +=
-      evaluationResult.reactProfiler.actualDuration || 0;
-    performanceResults.reactProfiler.phase +=
-      evaluationResult.reactProfiler.phase || 0;
-    performanceResults.reactProfiler.actualDuration +=
-      evaluationResult.reactProfiler.actualDuration || 0;
-    performanceResults.reactProfiler.baseDuration +=
-      evaluationResult.reactProfiler.baseDuration || 0;
-    performanceResults.reactProfiler.startTime +=
-      evaluationResult.reactProfiler.startTime || 0;
-    performanceResults.reactProfiler.commitTime +=
-      evaluationResult.reactProfiler.commitTime || 0;
+    if (evaluationResult.renderTime !== null) {
+      performanceResults.renderTime.push(evaluationResult.renderTime);
+    }
+
+    const webVitalMetrics = ['cls', 'lcp', 'inp', 'fid', 'ttfb'] as const;
+    for (const metric of webVitalMetrics) {
+      if (evaluationResult.webVitals[metric] !== null) {
+        performanceResults.webVitals[metric].push(
+          evaluationResult.webVitals[metric],
+        );
+      }
+    }
+
+    const profilerMetrics = [
+      'id',
+      'phase',
+      'actualDuration',
+      'baseDuration',
+      'startTime',
+      'commitTime',
+    ] as const;
+    for (const metric of profilerMetrics) {
+      if (evaluationResult.reactProfiler[metric] !== null) {
+        performanceResults.reactProfiler[metric].push(
+          evaluationResult.reactProfiler[metric],
+        );
+      }
+    }
 
     performanceResults.error = evaluationResult.error;
   }
@@ -159,14 +220,14 @@ function buildHtml(transpiled: string) {
       renderTime: null,
       webVitals: {},
       reactProfiler: {},
-      error: null
+      error: null,
     };
 
-    webVitals.onCLS((metric) => { window.__RESULT__.webVitals.cls = metric; });
-    webVitals.onLCP((metric) => { window.__RESULT__.webVitals.lcp = metric; });
-    webVitals.onINP((metric) => { window.__RESULT__.webVitals.inp = metric; });
-    webVitals.onFID((metric) => { window.__RESULT__.webVitals.fid = metric; });
-    webVitals.onTTFB((metric) => { window.__RESULT__.webVitals.ttfb = metric; });
+    webVitals.onCLS(({value}) => { window.__RESULT__.webVitals.cls = value; });
+    webVitals.onLCP(({value}) => { window.__RESULT__.webVitals.lcp = value; });
+    webVitals.onINP(({value}) => { window.__RESULT__.webVitals.inp = value; });
+    webVitals.onFID(({value}) => { window.__RESULT__.webVitals.fid = value; });
+    webVitals.onTTFB(({value}) => { window.__RESULT__.webVitals.ttfb = value; });
 
     try {
       ${transpiled}