DEV Community

Cover image for How react-render-profile-mcp works under the hood - and what it found in a real project
Albert Alov
Albert Alov

Posted on

How react-render-profile-mcp works under the hood - and what it found in a real project

I've been building react-render-profile-mcp for a few months — an MCP server that decodes React DevTools Profiler exports so AI agents can diagnose and now fix render performance. Earlier posts covered v0.1 and v0.3.1. This is v1.0.

I want to do two things in this post: show what it actually found on a real open source project, then explain how the engine works inside — because "it's just an MCP wrapper" is not the right mental model. 🐸


Act 1 — What it found on slash-admin

I ran the full cycle on slash-admin, a real React admin dashboard with Zustand and React Router. Target: UserProfile in src/pages/management/user/profile/index.tsx.

Diagnostics

get_render_summary → find_spurious_renders → analyze_compiler_efficacy → suggest_memoization
Enter fullscreen mode Exit fullscreen mode
  • 12 spurious renders on UserProfile
  • 42ms wasted
  • Trigger: UNSTABLE_PARENT_REF
  • Invalidation Index: 22.5
  • ROI score: 2.5 (threshold is 1.5)

The problem was two inline constants inside the render body:

function UserProfile() {
  const bgStyle: CSSProperties = {
    position: "absolute",
    inset: 0,
    background: `url(${bannerImage})`,
    backgroundSize: "cover",
    backgroundRepeat: "no-repeat",
  };

  const tabs = [
    { icon: , title: "\"Profile\" },"
    { icon: , title: "\"Followers\" },"
    // ...3 more
  ];

  return ( ... );
}
Enter fullscreen mode Exit fullscreen mode

New object reference on every render. Every memoized child downstream gets invalidated on every pass, regardless of whether anything actually changed.

Auto-remediation and the edge case

remediate_component runs three AST passes. On this file it hit a real edge case immediately:

import type { CSSProperties } from "react";
Enter fullscreen mode Exit fullscreen mode

Type-only import. Adding React.memo to the default export without fixing this would produce a runtime ReferenceError. The remediator detected it, rewrote the import, and continued:

-import type { CSSProperties } from "react";
+import React, { CSSProperties } from "react";
Enter fullscreen mode Exit fullscreen mode

Then the three passes ran:

-function UserProfile() {
-  const bgStyle: CSSProperties = { position: "absolute", inset: 0, ... };
-  const tabs = [{ icon: , title: "\"Profile\" }, ...];"
+const bgStyle: CSSProperties = { position: "absolute", inset: 0, ... };
+const tabs = [{ icon: , title: "\"Profile\" }, ...];"

+function UserProfile() {
   const { avatar, username } = useUserInfo();
   return ( ... );
 }

-export default UserProfile;
+export default React.memo(UserProfile);
Enter fullscreen mode Exit fullscreen mode

This gap in the original implementation was found by running on real code. A new unit test now covers the import type case. 57 tests passing.

42ms of spurious renders eliminated. Zero code written manually.


Act 2 — How it actually works inside

This is the part that matters if you want to understand what's happening, not just that it works.

Layer 1: decoding the profiler format

React DevTools exports version 5 — a format most developers have never had to parse manually. The tricky parts:

changeDescriptions is serialized as Map.entries(), not as a JSON object:

"changeDescriptions": [[3, {"isFirstMount": true}], [4, {"props": []}]]
Enter fullscreen mode Exit fullscreen mode

The parser normalizes this on load into Record so the rest of the code can work uniformly.

operations is an opcode integer array encoding tree mutations. Format:

[rendererID, rootFiberID, stringTableSize, ...strings, ...opcodes]

Opcode 1 (ADD):    [1, id, type, parentID, ownerID, nameStringIdx, keyIdx]
Opcode 2 (REMOVE): [2, count, id1, id2, ...]
Opcode 3 (REORDER_CHILDREN): [3, id, count, ...childIds]
Enter fullscreen mode Exit fullscreen mode

The parser walks this array to reconstruct the fiber name map, parent-child relationships, and unmount counts — all without a React runtime, just integer arithmetic and string table lookups.

Two name resolution strategies, with fallback:

  1. Primary: snapshots map (human-readable, always preferred)
  2. Fallback: decode the string table from operations opcodes

This dual strategy is what makes the parser robust against different DevTools export configurations.

Spurious render detection relies on one non-obvious React behavior: when a component re-renders due to an unstable prop reference but no prop values actually changed, React records props: [] (empty array) in changeDescriptions. props: null means unknown. props: ["value"] means the value prop genuinely changed. The parser uses exactly this signal:

function isSpurious(fiberID: number, commit: ProfileCommit): boolean {
  const desc = commit.changeDescriptions?.[String(fiberID)];
  if (!desc) return false;
  if (desc.isFirstMount || desc.context || desc.didHooksChange) return false;
  if (desc.state && desc.state.length > 0) return false;
  return desc.props !== null && desc.props.length === 0;
}
Enter fullscreen mode Exit fullscreen mode

React 18 concurrent mode false-positive prevention: startTransition and useDeferredValue can cause a component to render multiple times as React speculatively renders and discards incomplete trees. These show up with priorityLevel: "Low Priority" or "Idle". The parser tracks these separately so they don't get flagged as regressions.


Layer 2: the Invalidation Index

analyze_compiler_efficacy computes a score per component:

I = (spurious_count / total_renders) × wasted_ms
Enter fullscreen mode Exit fullscreen mode

This matters because raw wasted_ms alone can be misleading. A component with 100ms wasted across 100 renders (1ms each) has a very different profile than one with 100ms across 2 renders (50ms each). The index captures both the frequency and the cost — which is what determines whether React.memo overhead is actually worth it.

The threshold for suggest_memoization is avgSelfMs > 2ms. Below that, the Object.is comparison overhead of React.memo can exceed the render cost, making memoization actively harmful.


Layer 3: the AST remediation engine

ASTPerformanceRemediator uses ts-morph (TypeScript compiler API wrapper) to perform three passes over the source file:

Pass 1 — static hoisting. Walks the render body looking for VariableStatement nodes whose initializers are ObjectLiteralExpression or ArrayLiteralExpression. For each one, checks if any Identifier inside references a component-scope binding (props, state, hooks). If not — it's static and gets hoisted to module scope. This runs in a loop until no more hoistable statements are found, handling multiple declarations per component.

Pass 2 — useCallback wrapping. Walks ArrowFunction nodes inside the render body. For each one matching an unstable prop name, calls resolveReactiveDependencies — which walks all Identifier descendants of the arrow function and intersects them with the component's local scope bindings. This gives the dependency array automatically, without requiring the developer to reason about it manually.

Pass 3 — React.memo wrapping. Checks if ROI score exceeds 1.5, then finds the default export assignment and rewrites it. The import check (the edge case from slash-admin) now runs first: if the file only has import type ... from "react", it rewrites it to a value import before applying the wrapper.

All three passes operate on the live AST and call saveSync() once at the end — no intermediate file writes, no risk of partial state.


Why no React runtime dependency

Everything described above — opcode decoding, name resolution, spurious render detection, cascade tracing — runs on pure JSON and TypeScript. No React, no DevTools, no browser. This is intentional: the server needs to be fast to start (it runs as an npx command on every MCP client connection) and safe to run in any environment.


Setup

{
  "mcpServers": {
    "react-render-profile": {
      "command": "npx",
      "args": ["-y", "react-render-profile-mcp"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Export a profile: React DevTools → Profiler tab → Record → interact → Stop → Save icon (💾). Pass the .json path as profile_path.

GitHub: vola-trebla/react-render-profile-mcp
npm: npx react-render-profile-mcp


Questions about the opcode decoder or the dependency inference logic welcome. 🐸

Top comments (0)