Skip to content

Conversation

@yongsk0066
Copy link
Contributor

@yongsk0066 yongsk0066 commented Jan 27, 2026

Summary

Fixes an issue where DevTools Profiler reports components as "rendered" and "Highlight updates" flashes on components that were actually skipped.

This can cause confusion for developers using React Compiler, as cached components appear to be re-rendering in DevTools—making it seem like optimizations aren't working when they actually are.

When React Compiler caches JSX elements, the parent may bail out entirely (childLanes === 0), and child fibers are never visited by the reconciler. However, didFiberRender was checking the PerformedWork flag which could be stale from a previous render, causing false positives in the Profiler.

Here's an example to reproduce the issue (also available in my demo repo). Note that ChildComponent is wrapped with a <div>.

// See: https://github.com/yongsk0066/devtools-rendered-demo/blob/main/src/WrappedCase.tsx
import { useState } from "react";

export default function WrappedCase() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState("");

  return (
    <section>
      <h2>{"Child wrapped with <div>"}</h2>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Type here..."
      />
      <button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>
      <div>
        <ChildComponent count={count} />
      </div>
    </section>
  );
}

function ChildComponent({ count }: { count: number }) {
  return <p>Child Component {count}</p>;
}

Compiled output (some parts omitted, full output available in playground or demo repo):

import { c as _c } from "react/compiler-runtime";
import { useState } from "react";

export default function WrappedCase() {
  const $ = _c(12);
  const [count, setCount] = useState(0);
  const [text, setText] = useState("");
  // ...
  let t4;
  let t5;
  if ($[5] !== count) {
    t4 = <button onClick={t3}>Count: {count}</button>;
    t5 = (
      <div>
        <ChildComponent count={count} />
      </div>
    );
    $[5] = count;
    $[6] = t4;
    $[7] = t5;
  } else {
    t4 = $[6];
    t5 = $[7]; // same JSX reference returned
  }
  // When text changes, Parent re-renders but t5 stays the same.
  // React bails out on the cached subtree - ChildComponent fiber is never visited.
  // But Profiler incorrectly reports ChildComponent as "rendered".
  let t6;
  if ($[8] !== t2 || $[9] !== t4 || $[10] !== t5) {
    t6 = (
      <section>
        {t0}
        {t2}
        {t4}
        {t5}
      </section>
    );
    $[8] = t2;
    $[9] = t4;
    $[10] = t5;
    $[11] = t6;
  } else {
    t6 = $[11];
  }
  return t6;
}

I referenced a similar comparison pattern from renderer.js#L4936-L4940 (introduced in #30684), which uses fiber identity to check if a child was not visited. This fix adds the same check before checking the PerformedWork flag—if the fiber is the same object, it was never visited this render, so any flag is stale.

function didFiberRender(prevFiber, nextFiber) {
  if (prevFiber === nextFiber) {
    return false; // fiber was never visited
  }
  // ... existing flag check
}

Note: This is different from the "We don't reflect bailouts" case—that's for components that were visited but bailed out via shouldComponentUpdate or React.memo, while this fix handles fibers that were never visited because the parent bailed out entirely with childLanes === 0.

How did you test this change?

Added a test case using useMemoCache to simulate React Compiler's JSX caching behavior. The test verifies that cached children are not reported in fiberActualDurations.

I created a reproduction demo at https://github.com/yongsk0066/devtools-rendered-demo (live demo). Tested with React Developer Tools 7.0.1 (10/20/2025). To reproduce the issue, enable "Highlight updates when components render" and check if highlights appear on cached children, or check in Profiler if cached components are shown without hatching.

yarn test-build-devtools --testPathPattern="profilerStore-test" --testNamePattern="cached"

I also built the DevTools extension locally with the fix

Before fix:
profiling json

wrapped.mp4

After fix:
profiling json

fixed_wrapped.mp4

cc @hoxyq

@meta-cla meta-cla bot added the CLA Signed label Jan 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant