@samitouri / QOS-React-2 / commits / 24f215ce8b

[DevTools] Fix false-positive re-render reports for filtered nodes (#35723)

Fixes https://github.com/facebook/react/issues/33423, https://github.com/facebook/react/issues/35245, https://github.com/facebook/react/issues/19732. As demoed [here](https://github.com/facebook/react/issues/33423#issuecomment-2970750588), React DevTools incorrectly highlights re-renders for descendants of filtered-out nodes that didn't actually render. There were multiple fixes suggesting changes in `didFiberRender()` function, but these doesn't seem right, because this function is used in a context of whether the Fiber actually rendered something (updated), not re-rendered compared to the previous Fiber. Instead, this PR adds additional validation at callsites that either used for highlighting re-renders or capturing tree base durations and are relying on `didFiberRender`. I've also added a few tests that reproduce the failure scenario. Without the changes, the tests are failing.

Ruslan Lesiutin committed Feb 9, 2026 at 20:39 UTC 24f215ce8b0e17a230fbb7317919a6ae0c324f35
2 files changed +189 -18
packages/react-devtools-shared/src/__tests__/profilingCharts-test.js
+160
@@ -298,4 +298,164 @@ describe('profiling charts', () => {
298 `);
299 });
300 });
301 +
302 + describe('components behind filtered fibers should not report false re-renders', () => {
303 + it('should not report a component as re-rendered when its filtered parent bailed out', () => {
304 + let triggerUpdate;
305 +
306 + function Count() {
307 + const [count, setCount] = React.useState(0);
308 + triggerUpdate = () => setCount(c => c + 1);
309 + Scheduler.unstable_advanceTime(5);
310 + return count;
311 + }
312 +
313 + function Greeting() {
314 + Scheduler.unstable_advanceTime(3);
315 + return 'Hello';
316 + }
317 +
318 + function App() {
319 + Scheduler.unstable_advanceTime(1);
320 + return (
321 + <React.Fragment>
322 + <Count />
323 + <div>
324 + <Greeting />
325 + </div>
326 + </React.Fragment>
327 + );
328 + }
329 +
330 + utils.act(() => store.profilerStore.startProfiling());
331 + utils.act(() => render(<App />));
332 +
333 + // Verify tree structure: div is filtered, so Greeting appears as child of App
334 + expect(store).toMatchInlineSnapshot(`
335 + [root]
336 + ▾ <App>
337 + <Count>
338 + <Greeting>
339 + `);
340 +
341 + // Trigger a state update in Count. Should not cause Greeting to re-render.
342 + utils.act(() => triggerUpdate());
343 +
344 + utils.act(() => store.profilerStore.stopProfiling());
345 +
346 + const rootID = store.roots[0];
347 + const {chartData} = getFlamegraphChartData(rootID, 1);
348 + const allNodes = chartData.rows.flat();
349 +
350 + expect(allNodes).toEqual([
351 + expect.objectContaining({name: 'App', didRender: false}),
352 + expect.objectContaining({name: 'Greeting', didRender: false}),
353 + expect.objectContaining({name: 'Count', didRender: true}),
354 + ]);
355 + });
356 +
357 + it('should not report a component as re-rendered when behind a filtered fragment', () => {
358 + let triggerUpdate;
359 +
360 + function Count() {
361 + const [count, setCount] = React.useState(0);
362 + triggerUpdate = () => setCount(c => c + 1);
363 + Scheduler.unstable_advanceTime(5);
364 + return count;
365 + }
366 +
367 + function Greeting() {
368 + Scheduler.unstable_advanceTime(3);
369 + return 'Hello';
370 + }
371 +
372 + function App() {
373 + Scheduler.unstable_advanceTime(1);
374 + return (
375 + <React.Fragment>
376 + <Count />
377 + <React.Fragment>
378 + <Greeting />
379 + </React.Fragment>
380 + </React.Fragment>
381 + );
382 + }
383 +
384 + utils.act(() => store.profilerStore.startProfiling());
385 + utils.act(() => render(<App />));
386 +
387 + // Fragment with null key is filtered, so Greeting appears as child of App
388 + expect(store).toMatchInlineSnapshot(`
389 + [root]
390 + ▾ <App>
391 + <Count>
392 + <Greeting>
393 + `);
394 +
395 + // Trigger a state update in Count
396 + utils.act(() => triggerUpdate());
397 +
398 + utils.act(() => store.profilerStore.stopProfiling());
399 +
400 + const rootID = store.roots[0];
401 + const {chartData} = getFlamegraphChartData(rootID, 1);
402 + const allNodes = chartData.rows.flat();
403 +
404 + expect(allNodes).toEqual([
405 + expect.objectContaining({name: 'App', didRender: false}),
406 + expect.objectContaining({name: 'Greeting', didRender: false}),
407 + expect.objectContaining({name: 'Count', didRender: true}),
408 + ]);
409 + });
410 +
411 + it('should correctly report sibling components that did not re-render', () => {
412 + let triggerUpdate;
413 +
414 + function Count() {
415 + const [count, setCount] = React.useState(0);
416 + triggerUpdate = () => setCount(c => c + 1);
417 + Scheduler.unstable_advanceTime(5);
418 + return count;
419 + }
420 +
421 + function Greeting() {
422 + Scheduler.unstable_advanceTime(3);
423 + return 'Hello';
424 + }
425 +
426 + function App() {
427 + Scheduler.unstable_advanceTime(1);
428 + return (
429 + <React.Fragment>
430 + <Count />
431 + <Greeting />
432 + </React.Fragment>
433 + );
434 + }
435 +
436 + utils.act(() => store.profilerStore.startProfiling());
437 + utils.act(() => render(<App />));
438 +
439 + expect(store).toMatchInlineSnapshot(`
440 + [root]
441 + ▾ <App>
442 + <Count>
443 + <Greeting>
444 + `);
445 +
446 + utils.act(() => triggerUpdate());
447 +
448 + utils.act(() => store.profilerStore.stopProfiling());
449 +
450 + const rootID = store.roots[0];
451 + const {chartData} = getFlamegraphChartData(rootID, 1);
452 + const allNodes = chartData.rows.flat();
453 +
454 + expect(allNodes).toEqual([
455 + expect.objectContaining({name: 'App', didRender: false}),
456 + expect.objectContaining({name: 'Greeting', didRender: false}),
457 + expect.objectContaining({name: 'Count', didRender: true}),
458 + ]);
459 + });
460 + });
461 });
packages/react-devtools-shared/src/backend/fiber/renderer.js
+29 -18
@@ -2088,6 +2088,10 @@ export function attach(
2088 return changedKeys;
2089 }
2090
2091 + /**
2092 + * Returns true iff nextFiber actually performed any work and produced an update.
2093 + * For generic components, like Function or Class components, prevFiber is not considered.
2094 + */
2095 function didFiberRender(prevFiber: Fiber, nextFiber: Fiber): boolean {
2096 switch (nextFiber.tag) {
2097 case ClassComponent:
@@ -4520,7 +4524,10 @@ export function attach(
4524 pushOperation(convertedTreeBaseDuration);
4525 }
4526
4523 - if (prevFiber == null || didFiberRender(prevFiber, fiber)) {
4527 + if (
4528 + prevFiber == null ||
4529 + (prevFiber !== fiber && didFiberRender(prevFiber, fiber))
4530 + ) {
4531 if (actualDuration != null) {
4532 // The actual duration reported by React includes time spent working on children.
4533 // This is useful information, but it's also useful to be able to exclude child durations.
@@ -5150,11 +5157,13 @@ export function attach(
5157 elementType === ElementTypeMemo ||
5158 elementType === ElementTypeForwardRef
5159 ) {
5153 - // Otherwise if this is a traced ancestor, flag for the nearest host descendant(s).
5154 - traceNearestHostComponentUpdate = didFiberRender(
5155 - prevFiber,
5156 - nextFiber,
5157 - );
5160 + if (prevFiber !== nextFiber) {
5161 + // Otherwise if this is a traced ancestor, flag for the nearest host descendant(s).
5162 + traceNearestHostComponentUpdate = didFiberRender(
5163 + prevFiber,
5164 + nextFiber,
5165 + );
5166 + }
5167 }
5168 }
5169 }
@@ -5174,18 +5183,20 @@ export function attach(
5183 previousSuspendedBy = fiberInstance.suspendedBy;
5184 // Update the Fiber so we that we always keep the current Fiber on the data.
5185 fiberInstance.data = nextFiber;
5177 - if (
5178 - mostRecentlyInspectedElement !== null &&
5179 - (mostRecentlyInspectedElement.id === fiberInstance.id ||
5180 - // If we're inspecting a Root, we inspect the Screen.
5181 - // Invalidating any Root invalidates the Screen too.
5182 - (mostRecentlyInspectedElement.type === ElementTypeRoot &&
5183 - nextFiber.tag === HostRoot)) &&
5184 - didFiberRender(prevFiber, nextFiber)
5185 - ) {
5186 - // If this Fiber has updated, clear cached inspected data.
5187 - // If it is inspected again, it may need to be re-run to obtain updated hooks values.
5188 - hasElementUpdatedSinceLastInspected = true;
5186 + if (prevFiber !== nextFiber) {
5187 + if (
5188 + mostRecentlyInspectedElement !== null &&
5189 + (mostRecentlyInspectedElement.id === fiberInstance.id ||
5190 + // If we're inspecting a Root, we inspect the Screen.
5191 + // Invalidating any Root invalidates the Screen too.
5192 + (mostRecentlyInspectedElement.type === ElementTypeRoot &&
5193 + nextFiber.tag === HostRoot)) &&
5194 + didFiberRender(prevFiber, nextFiber)
5195 + ) {
5196 + // If this Fiber has updated, clear cached inspected data.
5197 + // If it is inspected again, it may need to be re-run to obtain updated hooks values.
5198 + hasElementUpdatedSinceLastInspected = true;
5199 + }
5200 }
5201 // Push a new DevTools instance parent while reconciling this subtree.
5202 reconcilingParent = fiberInstance;