From 24f215ce8b0e17a230fbb7317919a6ae0c324f35 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:39:33 +0000 Subject: [PATCH 1/4] [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. --- .../src/__tests__/profilingCharts-test.js | 160 ++++++++++++++++++ .../src/backend/fiber/renderer.js | 47 +++-- 2 files changed, 189 insertions(+), 18 deletions(-) diff --git a/packages/react-devtools-shared/src/__tests__/profilingCharts-test.js b/packages/react-devtools-shared/src/__tests__/profilingCharts-test.js index e54af61d6209..da20511a650e 100644 --- a/packages/react-devtools-shared/src/__tests__/profilingCharts-test.js +++ b/packages/react-devtools-shared/src/__tests__/profilingCharts-test.js @@ -298,4 +298,164 @@ describe('profiling charts', () => { `); }); }); + + describe('components behind filtered fibers should not report false re-renders', () => { + it('should not report a component as re-rendered when its filtered parent bailed out', () => { + let triggerUpdate; + + function Count() { + const [count, setCount] = React.useState(0); + triggerUpdate = () => setCount(c => c + 1); + Scheduler.unstable_advanceTime(5); + return count; + } + + function Greeting() { + Scheduler.unstable_advanceTime(3); + return 'Hello'; + } + + function App() { + Scheduler.unstable_advanceTime(1); + return ( + + +
+ +
+
+ ); + } + + utils.act(() => store.profilerStore.startProfiling()); + utils.act(() => render()); + + // Verify tree structure: div is filtered, so Greeting appears as child of App + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + + + `); + + // Trigger a state update in Count. Should not cause Greeting to re-render. + utils.act(() => triggerUpdate()); + + utils.act(() => store.profilerStore.stopProfiling()); + + const rootID = store.roots[0]; + const {chartData} = getFlamegraphChartData(rootID, 1); + const allNodes = chartData.rows.flat(); + + expect(allNodes).toEqual([ + expect.objectContaining({name: 'App', didRender: false}), + expect.objectContaining({name: 'Greeting', didRender: false}), + expect.objectContaining({name: 'Count', didRender: true}), + ]); + }); + + it('should not report a component as re-rendered when behind a filtered fragment', () => { + let triggerUpdate; + + function Count() { + const [count, setCount] = React.useState(0); + triggerUpdate = () => setCount(c => c + 1); + Scheduler.unstable_advanceTime(5); + return count; + } + + function Greeting() { + Scheduler.unstable_advanceTime(3); + return 'Hello'; + } + + function App() { + Scheduler.unstable_advanceTime(1); + return ( + + + + + + + ); + } + + utils.act(() => store.profilerStore.startProfiling()); + utils.act(() => render()); + + // Fragment with null key is filtered, so Greeting appears as child of App + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + + + `); + + // Trigger a state update in Count + utils.act(() => triggerUpdate()); + + utils.act(() => store.profilerStore.stopProfiling()); + + const rootID = store.roots[0]; + const {chartData} = getFlamegraphChartData(rootID, 1); + const allNodes = chartData.rows.flat(); + + expect(allNodes).toEqual([ + expect.objectContaining({name: 'App', didRender: false}), + expect.objectContaining({name: 'Greeting', didRender: false}), + expect.objectContaining({name: 'Count', didRender: true}), + ]); + }); + + it('should correctly report sibling components that did not re-render', () => { + let triggerUpdate; + + function Count() { + const [count, setCount] = React.useState(0); + triggerUpdate = () => setCount(c => c + 1); + Scheduler.unstable_advanceTime(5); + return count; + } + + function Greeting() { + Scheduler.unstable_advanceTime(3); + return 'Hello'; + } + + function App() { + Scheduler.unstable_advanceTime(1); + return ( + + + + + ); + } + + utils.act(() => store.profilerStore.startProfiling()); + utils.act(() => render()); + + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + + + `); + + utils.act(() => triggerUpdate()); + + utils.act(() => store.profilerStore.stopProfiling()); + + const rootID = store.roots[0]; + const {chartData} = getFlamegraphChartData(rootID, 1); + const allNodes = chartData.rows.flat(); + + expect(allNodes).toEqual([ + expect.objectContaining({name: 'App', didRender: false}), + expect.objectContaining({name: 'Greeting', didRender: false}), + expect.objectContaining({name: 'Count', didRender: true}), + ]); + }); + }); }); diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index bc1b2a590d59..7363c66d15c8 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2088,6 +2088,10 @@ export function attach( return changedKeys; } + /** + * Returns true iff nextFiber actually performed any work and produced an update. + * For generic components, like Function or Class components, prevFiber is not considered. + */ function didFiberRender(prevFiber: Fiber, nextFiber: Fiber): boolean { switch (nextFiber.tag) { case ClassComponent: @@ -4520,7 +4524,10 @@ export function attach( pushOperation(convertedTreeBaseDuration); } - if (prevFiber == null || didFiberRender(prevFiber, fiber)) { + if ( + prevFiber == null || + (prevFiber !== fiber && didFiberRender(prevFiber, fiber)) + ) { if (actualDuration != null) { // The actual duration reported by React includes time spent working on children. // This is useful information, but it's also useful to be able to exclude child durations. @@ -5150,11 +5157,13 @@ export function attach( elementType === ElementTypeMemo || elementType === ElementTypeForwardRef ) { - // Otherwise if this is a traced ancestor, flag for the nearest host descendant(s). - traceNearestHostComponentUpdate = didFiberRender( - prevFiber, - nextFiber, - ); + if (prevFiber !== nextFiber) { + // Otherwise if this is a traced ancestor, flag for the nearest host descendant(s). + traceNearestHostComponentUpdate = didFiberRender( + prevFiber, + nextFiber, + ); + } } } } @@ -5174,18 +5183,20 @@ export function attach( previousSuspendedBy = fiberInstance.suspendedBy; // Update the Fiber so we that we always keep the current Fiber on the data. fiberInstance.data = nextFiber; - if ( - mostRecentlyInspectedElement !== null && - (mostRecentlyInspectedElement.id === fiberInstance.id || - // If we're inspecting a Root, we inspect the Screen. - // Invalidating any Root invalidates the Screen too. - (mostRecentlyInspectedElement.type === ElementTypeRoot && - nextFiber.tag === HostRoot)) && - didFiberRender(prevFiber, nextFiber) - ) { - // If this Fiber has updated, clear cached inspected data. - // If it is inspected again, it may need to be re-run to obtain updated hooks values. - hasElementUpdatedSinceLastInspected = true; + if (prevFiber !== nextFiber) { + if ( + mostRecentlyInspectedElement !== null && + (mostRecentlyInspectedElement.id === fiberInstance.id || + // If we're inspecting a Root, we inspect the Screen. + // Invalidating any Root invalidates the Screen too. + (mostRecentlyInspectedElement.type === ElementTypeRoot && + nextFiber.tag === HostRoot)) && + didFiberRender(prevFiber, nextFiber) + ) { + // If this Fiber has updated, clear cached inspected data. + // If it is inspected again, it may need to be re-run to obtain updated hooks values. + hasElementUpdatedSinceLastInspected = true; + } } // Push a new DevTools instance parent while reconciling this subtree. reconcilingParent = fiberInstance; From 4c9d62d2b47be424ad9050725d8bdd8df12fe2a3 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Mon, 9 Feb 2026 22:52:24 +0100 Subject: [PATCH 2/4] [DevTools] Fix crash when revealing stable, filtered `` children (#35734) --- .../src/__tests__/store-test.js | 44 +++++++++++++++++++ .../src/backend/fiber/renderer.js | 11 +++++ 2 files changed, 55 insertions(+) diff --git a/packages/react-devtools-shared/src/__tests__/store-test.js b/packages/react-devtools-shared/src/__tests__/store-test.js index 5e39a0fe68f9..871b44827e77 100644 --- a/packages/react-devtools-shared/src/__tests__/store-test.js +++ b/packages/react-devtools-shared/src/__tests__/store-test.js @@ -3572,4 +3572,48 @@ describe('Store', () => { `); }); + + // @reactVersion >= 19 + it('can reconcile newly visible Activity with filtered, stable children', async () => { + const Activity = React.Activity || React.unstable_Activity; + + function IgnoreMe({children}) { + return children; + } + + function Component({children}) { + return
{children}
; + } + + await actAsync( + async () => + (store.componentFilters = [createDisplayNameFilter('^IgnoreMe', true)]), + ); + + const children = ( + + Left + + ); + + await actAsync(() => { + render({children}); + }); + + expect(store).toMatchInlineSnapshot(` + [root] + + `); + + await actAsync(() => { + render({children}); + }); + + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + ▾ +
+ `); + }); }); diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 7363c66d15c8..61c6f86dd6b4 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -5416,6 +5416,17 @@ export function attach( // We're hiding the children. Remove them from the Frontend unmountRemainingChildren(); } + } else if (prevWasHidden && !nextIsHidden) { + // Since we don't mount hidden children and unmount children when hiding, + // we need to enter the mount path when revealing. + const nextChildSet = nextFiber.child; + if (nextChildSet !== null) { + mountChildrenRecursively( + nextChildSet, + traceNearestHostComponentUpdate, + ); + updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren; + } } else if ( nextFiber.tag === SuspenseComponent && OffscreenComponent !== -1 && From 6a939d0b54a0e1aded58652a307cfc083b691d67 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Mon, 9 Feb 2026 23:14:46 +0100 Subject: [PATCH 3/4] [DevTools] Allow renaming Host Component props (#35735) --- .../src/__tests__/editing-test.js | 15 ++++++++++- .../src/backend/fiber/renderer.js | 25 +++++++++++-------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/react-devtools-shared/src/__tests__/editing-test.js b/packages/react-devtools-shared/src/__tests__/editing-test.js index 750e13e38d5d..532b538429e4 100644 --- a/packages/react-devtools-shared/src/__tests__/editing-test.js +++ b/packages/react-devtools-shared/src/__tests__/editing-test.js @@ -82,7 +82,12 @@ describe('editing interface', () => { shallow="initial" /> , - + , ), ); @@ -259,6 +264,14 @@ describe('editing interface', () => { }, after: 'initial', }); + renamePath(hostComponentID, ['data-foo'], ['data-bar']); + expect({ + foo: inputRef.current.dataset.foo, + bar: inputRef.current.dataset.bar, + }).toEqual({ + foo: undefined, + bar: 'test', + }); }); // @reactVersion >= 16.9 diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 61c6f86dd6b4..5a977dc762bb 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -7874,17 +7874,20 @@ export function attach( } break; case 'props': - if (instance === null) { - if (typeof overridePropsRenamePath === 'function') { - overridePropsRenamePath(fiber, oldPath, newPath); - } - } else { - fiber.pendingProps = copyWithRename( - instance.props, - oldPath, - newPath, - ); - instance.forceUpdate(); + switch (fiber.tag) { + case ClassComponent: + fiber.pendingProps = copyWithRename( + instance.props, + oldPath, + newPath, + ); + instance.forceUpdate(); + break; + default: + if (typeof overridePropsRenamePath === 'function') { + overridePropsRenamePath(fiber, oldPath, newPath); + } + break; } break; case 'state': From c6bb26bf833c5d91760daf28fa2750b81067ac30 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:18:17 +0000 Subject: [PATCH 4/4] [DevTools] Don't capture durations for disconnected subtrees when profiling (#35718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After https://github.com/facebook/react/pull/34089, when updating (possibly, mounting) inside disconnected subtree, we don't record this as an operation. This only happens during reconnect. The issue is that `recordProfilingDurations()` can be called, which diffs tree base duration and reports it to the Frontend: https://github.com/facebook/react/blob/65db1000b944c8a07b5947c06b38eb8364dce4f2/packages/react-devtools-shared/src/backend/fiber/renderer.js#L4506-L4521 This operation can be recorded before the "Add" operation, and it will not be resolved properly on the Frontend side. Before the fix: ``` commit tree › Suspense › should handle transitioning from fallback back to content during profiling Could not clone the node: commit tree does not contain fiber "5". This is a bug in React DevTools. 162 | const existingNode = nodes.get(id); 163 | if (existingNode == null) { > 164 | throw new Error( | ^ 165 | `Could not clone the node: commit tree does not contain fiber "${id}". This is a bug in React DevTools.`, 166 | ); 167 | } at getClonedNode (packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js:164:13) at updateTree (packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js:348:24) at getCommitTree (packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js:112:20) at ProfilingCache.getCommitTree (packages/react-devtools-shared/src/devtools/ProfilingCache.js:40:46) at Object. (packages/react-devtools-shared/src/__tests__/profilingCommitTreeBuilder-test.js:257:44) ``` --- .../profilingCommitTreeBuilder-test.js | 69 +++++++++++++++++++ .../src/backend/fiber/renderer.js | 10 +-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/packages/react-devtools-shared/src/__tests__/profilingCommitTreeBuilder-test.js b/packages/react-devtools-shared/src/__tests__/profilingCommitTreeBuilder-test.js index f08629dd05df..5879032bada1 100644 --- a/packages/react-devtools-shared/src/__tests__/profilingCommitTreeBuilder-test.js +++ b/packages/react-devtools-shared/src/__tests__/profilingCommitTreeBuilder-test.js @@ -191,4 +191,73 @@ describe('commit tree', () => { expect(commitTrees[1].nodes.size).toBe(2); // + }); }); + + describe('Suspense', () => { + it('should handle transitioning from fallback back to content during profiling', async () => { + let resolvePromise; + let promise = null; + let childTreeBaseDuration = 10; + + function Wrapper({children}) { + Scheduler.unstable_advanceTime(2); + return children; + } + + function Child() { + Scheduler.unstable_advanceTime(childTreeBaseDuration); + if (promise !== null) { + React.use(promise); + } + return ; + } + + function GrandChild() { + Scheduler.unstable_advanceTime(5); + return null; + } + + function Fallback() { + Scheduler.unstable_advanceTime(2); + return null; + } + + function App() { + Scheduler.unstable_advanceTime(1); + return ( + }> + + + + + ); + } + + utils.act(() => store.profilerStore.startProfiling()); + + // Commit 1: Mount with primary + utils.act(() => render()); + + // Commit 2: Suspend, show fallback + promise = new Promise(resolve => (resolvePromise = resolve)); + await utils.actAsync(() => render()); + + // Commit 3: Resolve suspended promise, show primary content with a different duration. + childTreeBaseDuration = 20; + promise = null; + await utils.actAsync(resolvePromise); + utils.act(() => render()); + + utils.act(() => store.profilerStore.stopProfiling()); + + const rootID = store.roots[0]; + const dataForRoot = store.profilerStore.getDataForRoot(rootID); + const numCommits = dataForRoot.commitData.length; + for (let commitIndex = 0; commitIndex < numCommits; commitIndex++) { + store.profilerStore.profilingCache.getCommitTree({ + commitIndex, + rootID, + }); + } + }); + }); }); diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 5a977dc762bb..3214c8392285 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -5581,10 +5581,12 @@ export function attach( } recordConsoleLogs(fiberInstance, componentLogsEntry); - const isProfilingSupported = - nextFiber.hasOwnProperty('treeBaseDuration'); - if (isProfilingSupported) { - recordProfilingDurations(fiberInstance, prevFiber); + if (!isInDisconnectedSubtree) { + const isProfilingSupported = + nextFiber.hasOwnProperty('treeBaseDuration'); + if (isProfilingSupported) { + recordProfilingDurations(fiberInstance, prevFiber); + } } } }