Skip to content

[DevTools] Fix Profiler inaccuracy caused by stale PerformedWork flag on unvisited fibers#35645

Open
yongsk0066 wants to merge 1 commit intofacebook:mainfrom
yongsk0066:fix/devtools-didFiberRender-accuracy
Open

[DevTools] Fix Profiler inaccuracy caused by stale PerformedWork flag on unvisited fibers#35645
yongsk0066 wants to merge 1 commit intofacebook:mainfrom
yongsk0066:fix/devtools-didFiberRender-accuracy

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
@yongsk0066
Copy link
Contributor Author

Hey @hoxyq ! The DevTools/Compiler mismatch in profiler has been bugging me. Would appreciate if you could take a look :)

@hoxyq
Copy link
Contributor

hoxyq commented Feb 2, 2026

Hey @hoxyq ! The DevTools/Compiler mismatch in profiler has been bugging me. Would appreciate if you could take a look :)

Hey, thank you for upstreaming this!

The issue seems similar to #33423. Could you please confirm if my observations apply to your case as well?

If so, then I left some thoughts on potential solution here. We could start by adding a test that fails with a current implementation and then triage the reconciliation logic in DevTools.

@hoxyq hoxyq self-requested a review February 2, 2026 16:01
@yongsk0066
Copy link
Contributor Author

Hey @hoxyq ! The DevTools/Compiler mismatch in profiler has been bugging me. Would appreciate if you could take a look :)

Hey, thank you for upstreaming this!

The issue seems similar to #33423. Could you please confirm if my observations apply to your case as well?

If so, then I left some thoughts on potential solution here. We could start by adding a test that fails with a current implementation and then triage the reconciliation logic in DevTools.

Yeah, I think this is the same issue. My case is triggered by the Compiler's caching, but it's essentially a bailout case too - both end up with identical fiber references that shouldn't be marked as rendered.

I retested the #33423 repro with the latest DevTools, and the issue still occurs even with the DOM filter disabled. So I don't think filtering is the root cause here.

2026-02-03.1.22.00.mov

With my fix applied, both cases work correctly:

2026-02-03.1.27.07.mov

The actual root cause seems to be: when a parent bails out, children aren't visited, so createWorkInProgress never gets called and the flags stay stale.
That's why DevTools shows them as rendered even though they weren't. (The render duration also shows stale values btw.)

So checking fiber identity makes sense here - if prevFiber === nextFiber, the fiber was never visited in the first place. It's not specifically about HostComponent parents.

@hoxyq
Copy link
Contributor

hoxyq commented Feb 2, 2026

I retested the #33423 repro with the latest DevTools, and the issue still occurs even with the DOM filter disabled.

Something is wrong here, these seems to be identical issues.

If I make this change in the test you've provided:

t0 = (
-  <div>
-    <Child count={count} />
-  </div>
+  <Child count={count} />
);

the test passes.

@yongsk0066
Copy link
Contributor Author

yongsk0066 commented Feb 3, 2026

I retested the #33423 repro with the latest DevTools, and the issue still occurs even with the DOM filter disabled.

Something is wrong here, these seems to be identical issues.

If I make this change in the test you've provided:

t0 = (
-  <div>
-    <Child count={count} />
-  </div>
+  <Child count={count} />
);

the test passes.

You're right, sorry about the confusion. the DOM filter wasn't properly applied when I tested (seems like it doesn't take effect immediately after refresh).

As I understand it, DOM filter is just for visual preference in DevTools. whether to show/hide DOM elements in the tree. It shouldn't affect how we determine if a component actually rendered or not, right? So the real issue is that DOM filter ON vs OFF produces different results for render detection, when it should be consistent.

Preventing the child traversal for filtered fibers could be an option, but looking at this comment #L5520-L5521,

       // If this fiber is filtered there might be changes to this set elsewhere so we have
       // to visit each child to place it back in the set. We let the child bail out instead.

it seems like the traversal is needed to manage their placement in DevTools tree. So I think handling it in didFiberRender makes sense. if the fiber was never actually visited (prevFiber === nextFiber), it should return false. This way, both profiler and highlight get fixed in one place, and it fits didFiberRender's responsibility of answering "did this fiber render?".

The test I added fails on main and passes with my fix. Let me know if you'd like me to adjust anything. :)

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.

2 participants