Skip to content

Improve the detection of changed hooks - #35123

Merged
hoxyq merged 17 commits into
react:mainfrom
blazejkustra:fix/devtools-hook-indexes-mismatch
Jan 15, 2026
Merged

Improve the detection of changed hooks#35123
hoxyq merged 17 commits into
react:mainfrom
blazejkustra:fix/devtools-hook-indexes-mismatch

Conversation

@blazejkustra

@blazejkustrablazejkustra commented Nov 13, 2025

Copy link
Copy Markdown
Contributor

Summary

cc @hoxyq

Fixes#28584. Follow up to PR: #34547

This PR updates getChangedHooksIndices to account for the fact that useSyncExternalStore, useTransition, useActionState, useFormState internally mounts more than one hook while DevTools should treat it as a single user-facing hook.

Approach idea came from this comment 😄

Before:

QuickTime.movie.2.mov

After:

QuickTime.movie.mov

How did you test this change?

I used this component to reproduce this issue locally (I followed instructions in packages/react-devtools/CONTRIBUTING.md).

Details
import*asReactfrom'react';functionuseDeepNestedHook(){React.useState(0);// 1returnReact.useState(1);// 2}functionuseNestedHook(){constdeepState=useDeepNestedHook();React.useState(2);// 3React.useState(3);// 4returndeepState;}// Create a simple store for useSyncExternalStorefunctioncreateStore(initialValue){letvalue=initialValue;constlisteners=newSet();return{getSnapshot: ()=>value,subscribe: listener=>{listeners.add(listener);return()=>{listeners.delete(listener);};},update: newValue=>{value=newValue;listeners.forEach(listener=>listener());},};}constsyncExternalStore=createStore(0);exportdefaultfunctionInspectableElements(): React.Node{const[nestedState,setNestedState]=useNestedHook();// 5constsyncExternalValue=React.useSyncExternalStore(syncExternalStore.subscribe,syncExternalStore.getSnapshot,);// 6const[isPending,startTransition]=React.useTransition();// 7const[formState,formAction,formPending]=React.useActionState(async(prevState,formData)=>{return{count: (prevState?.count||0)+1};},{count: 0},);consthandleTransition=()=>{startTransition(()=>{setState(Math.random());});};// 8const[state,setState]=React.useState('test');return(<><divstyle={{padding: '20px',display: 'flex',flexDirection: 'column',gap: '10px',}}><divonClick={()=>setNestedState(Math.random())}style={{backgroundColor: 'red',padding: '10px',cursor: 'pointer'}}>State: {nestedState}</div><buttononClick={handleTransition}style={{padding: '10px'}}>TriggerTransition{isPending ? '(pending...)' : ''}</button><divstyle={{display: 'flex',gap: '10px',alignItems: 'center'}}><buttononClick={()=>syncExternalStore.update(syncExternalValue+1)}style={{padding: '10px'}}>TriggeruseSyncExternalStore</button><span>Value: {syncExternalValue}</span></div><formaction={formAction}style={{display: 'flex',gap: '10px',alignItems: 'center'}}><buttontype="submit"style={{padding: '10px'}}disabled={formPending}>TriggeruseFormState{formPending ? '(pending...)' : ''}</button><span>Count: {formState.count}</span></form><divonClick={()=>setState(Math.random())}style={{backgroundColor: 'red',padding: '10px',cursor: 'pointer'}}>State: {state}</div></div></>);}

@react-sizebot

react-sizebot commented Nov 13, 2025

Copy link
Copy Markdown

Comparing: 4a3d993...6a9c7bd

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name+/-BaseCurrent+/- gzipBase gzipCurrent gzip
oss-stable/react-dom/cjs/react-dom.production.js=6.84 kB6.84 kB+0.05%1.88 kB1.88 kB
oss-stable/react-dom/cjs/react-dom-client.production.js=608.03 kB608.03 kB=107.61 kB107.61 kB
oss-experimental/react-dom/cjs/react-dom.production.js=6.84 kB6.84 kB=1.88 kB1.88 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js=667.26 kB667.26 kB=117.51 kB117.51 kB
facebook-www/ReactDOM-prod.classic.js=693.38 kB693.38 kB=122.00 kB122.00 kB
facebook-www/ReactDOM-prod.modern.js=683.76 kB683.76 kB=120.40 kB120.40 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name+/-BaseCurrent+/- gzipBase gzipCurrent gzip
oss-experimental/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-experimental/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB

Generated by 🚫 dangerJS against 6a9c7bd

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

@hoxyq I may need your help... Two tests are failing and I don't understand what's wrong

  • profilingCache-test.js › should properly detect changed hooks
  • profilingCache-test.js › should detect context changes or lack of changes with conditional use()

They only fail when I call:

constprevHooks=inspectHooks(prevFiber);constnextHooks=inspectHooks(nextFiber);

I’ve narrowed it to this:

exportfunctioninspectHooks<Props>(renderFunction: (props: Props)=>React$Node,props: Props,currentDispatcher?: CurrentDispatcherRef,): HooksTree{if(currentDispatcher==null){currentDispatcher=ReactSharedInternals;}constpreviousDispatcher=currentDispatcher.H;currentDispatcher.H=DispatcherProxy;letreadHookLog;letancestorStackError;try{ancestorStackError=newError();renderFunction(props);}catch(error){handleRenderFunctionError(error);}finally{readHookLog=hookLog;hookLog=[];// $FlowFixMe[incompatible-use] found when upgrading FlowcurrentDispatcher.H=previousDispatcher;}constrootStack=ancestorStackError===undefined
? ([]: ParsedStackFrame[])
: ErrorStackParser.parse(ancestorStackError);returnbuildTree(rootStack,readHookLog);}

I suspect the issue comes from how inspectHooks temporarily swaps out the dispatcher. That dispatcher replacement might be overwriting or leaking some internal data, which then causes the change descriptions recorded by the Profiler to become incomplete.

@hoxyq
hoxyq self-requested a review November 19, 2025 19:24
@hoxyq

Copy link
Copy Markdown
Collaborator

Looking at the failed tests, it looks like the number of recorded changes doesn't match the number of performed updates. I don't see any early returns in the code, so I am not sure how this may actually happen.

I wonder if this could be related to the fact that we are actually calling render functions of components to build the hook tree.

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

FYI I'm looking into it again, maybe merging main would help 🤞

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

I can't run tests on the newest main due to this, is this a known problem or should I build differently? @hoxyq

@andreisilviudragnea

Copy link
Copy Markdown

Please have a look at #34427 too, maybe it's another solution to this issue @blazejkustra@hoxyq

@andreisilviudragnea

andreisilviudragnea commented Dec 25, 2025

Copy link
Copy Markdown

I think that merging #34547 without fixing the index counting error for all composite hooks just does not fix the initial problem at all, because having for example a useTransition hook before any other hook will invalidate the "Hook X changed" index for all hooks that follow useTransition

@blazejkustra

blazejkustra commented Dec 25, 2025

Copy link
Copy Markdown
ContributorAuthor

I think that merging #34547 without fixing the index counting error for all composite hooks just does not fix the initial problem at all, because having for example a useTransition hook before any other hook will invalidate the "Hook X changed" index for all hooks that follow useTransition

@andreisilviudragnea read through the discussion on my previous PR, especially this comment. The plan was to merge it and fix other hooks in follow up PRs. However, it turned out to be harder than expected and I haven't figure it out yet 🫠

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

It's a Christmas miracle!! 🎄

After some trial and error I finally realized that due to how inspectHooks() works the dispatcher was mocked for a moment, which overwrote the dispatch/setState variables the tests were storing 'globally'. I fixed it by saving references before they get overwritten.

Back to you @hoxyq 😄

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated
Comment on lines +2050 to +2054
// If the hook has subHooks, flatten them recursively
if (currentHook.subHooks && currentHook.subHooks.length > 0) {
flattened.push(...flattenHooksTree(currentHook.subHooks));
continue;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why this specific order is chosen?

This looks like subHooks wills have lower indexes in an array, when flattened. In the UI, the hook numbers (index + 1) usually reflect the tree structure. Basically indexOf(parent) < indexOf(parent.child) is always true.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flattenHooksTree function performs a depth-first traversal that only keeps leaf hooks (those without subHooks). This means custom hooks are unwrapped to primitive hooks.

For example, given this tree:

[
{ name: 'useCustomHook', subHooks: [
{ name: 'useState', value: 1 },
{ name: 'useEffect' }
]},
{ name: 'useState', value: 2 }
]

The flattened result is:

[
{ name: 'useState', value: 1 }, // index 0
{ name: 'useEffect' }, // index 1
{ name: 'useState', value: 2 } // index 2
]

This order matches React's hooks order, right? So comparing prevFlattened[i] with nextFlattened[i] identifies which primitive hooks changed between renders.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we were to change the traversal order the indexes wouldn't be correct I believe, wdyt @hoxyq?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying. I was confused a bit, but that is not an issue, since we only assign indexes to built-in hooks, which should be leaf nodes by definition.

In your example above, I believe this will be the result of flattening:

[
{ name: 'State', value: 1 }, // index 0
{ name: 'Effect' }, // index 1
{ name: 'CustomHook' }, { name: 'State', value: 2 } // index 2
]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it still makes sense to do flattening? We could probably just do dfs on both hook trees at the same time.

@blazejkustrablazejkustraJan 14, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think custom hook would not be there since it is omitted by the continue statement:

if(currentHook.subHooks&&currentHook.subHooks.length>0){flattened.push(...flattenHooksTree(currentHook.subHooks));continue;}

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably just do dfs on both hook trees at the same time.

Ahaa, so instead of building prevFlattened and nextFlattened I could do it in place? Good idea, let me try it 👀

@hoxyq

Copy link
Copy Markdown
Collaborator

These changes make sense, thank you for improving this. I left some clarifying questions.

Could you please also add some tests that will validate these changes? For example, the different (and correct) result of inspectHooks() calls when a complex component with useSyncExternalStore() or other hooks is used.

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

Back to you @hoxyq! I adjusted the code so it traverses in place and added a test as asked 🚀

I tried to run this new test on main and it failed miserably as expected :)

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated

@hoxyqhoxyq left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, please see my suggestion on making iterating over hook trees without optionality.

I can merge after it. We've accumulated some fixes that are not released yet, so I might release new patch / minor release later this month, but no concrete plans yet.

Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

I added your suggestions, seems that everything works well. Thank you!

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated
Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
@hoxyq
hoxyq merged commit 53daaf5 into react:mainJan 15, 2026
234 checks passed
@blazejkustra

blazejkustra commented Jan 15, 2026

Copy link
Copy Markdown
ContributorAuthor

I was wrong lol, good catch!

mdm317 added a commit to mdm317/react that referenced this pull request Apr 20, 2026
inspectHooksOfFiber is too expensive for this use case. On frequently updating
components, it can become slow enough to freeze the browser.
Revert hook change detection to the pre-react#35123 behavior. Then defer parsing
hook metadata, such as hook names, until after the commit completes, and add
caching so hook inspection runs at most once per fiber whenever possible.
For now, special handling is only implemented for SyncExternalStore,
Transition, ActionState, and FormState.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[DevTools Bug]: React Profiler reports higher hook numbers than shown in Components

4 participants

@blazejkustra@react-sizebot@hoxyq@andreisilviudragnea
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Improve the detection of changed hooks by blazejkustra · Pull Request #35123 · react/react · GitHub
Skip to content

Improve the detection of changed hooks - #35123

Merged
hoxyq merged 17 commits into
react:mainfrom
blazejkustra:fix/devtools-hook-indexes-mismatch
Jan 15, 2026
Merged

Improve the detection of changed hooks#35123
hoxyq merged 17 commits into
react:mainfrom
blazejkustra:fix/devtools-hook-indexes-mismatch

Conversation

@blazejkustra

@blazejkustrablazejkustra commented Nov 13, 2025

Copy link
Copy Markdown
Contributor

Summary

cc @hoxyq

Fixes#28584. Follow up to PR: #34547

This PR updates getChangedHooksIndices to account for the fact that useSyncExternalStore, useTransition, useActionState, useFormState internally mounts more than one hook while DevTools should treat it as a single user-facing hook.

Approach idea came from this comment 😄

Before:

QuickTime.movie.2.mov

After:

QuickTime.movie.mov

How did you test this change?

I used this component to reproduce this issue locally (I followed instructions in packages/react-devtools/CONTRIBUTING.md).

Details
import*asReactfrom'react';functionuseDeepNestedHook(){React.useState(0);// 1returnReact.useState(1);// 2}functionuseNestedHook(){constdeepState=useDeepNestedHook();React.useState(2);// 3React.useState(3);// 4returndeepState;}// Create a simple store for useSyncExternalStorefunctioncreateStore(initialValue){letvalue=initialValue;constlisteners=newSet();return{getSnapshot: ()=>value,subscribe: listener=>{listeners.add(listener);return()=>{listeners.delete(listener);};},update: newValue=>{value=newValue;listeners.forEach(listener=>listener());},};}constsyncExternalStore=createStore(0);exportdefaultfunctionInspectableElements(): React.Node{const[nestedState,setNestedState]=useNestedHook();// 5constsyncExternalValue=React.useSyncExternalStore(syncExternalStore.subscribe,syncExternalStore.getSnapshot,);// 6const[isPending,startTransition]=React.useTransition();// 7const[formState,formAction,formPending]=React.useActionState(async(prevState,formData)=>{return{count: (prevState?.count||0)+1};},{count: 0},);consthandleTransition=()=>{startTransition(()=>{setState(Math.random());});};// 8const[state,setState]=React.useState('test');return(<><divstyle={{padding: '20px',display: 'flex',flexDirection: 'column',gap: '10px',}}><divonClick={()=>setNestedState(Math.random())}style={{backgroundColor: 'red',padding: '10px',cursor: 'pointer'}}>State: {nestedState}</div><buttononClick={handleTransition}style={{padding: '10px'}}>TriggerTransition{isPending ? '(pending...)' : ''}</button><divstyle={{display: 'flex',gap: '10px',alignItems: 'center'}}><buttononClick={()=>syncExternalStore.update(syncExternalValue+1)}style={{padding: '10px'}}>TriggeruseSyncExternalStore</button><span>Value: {syncExternalValue}</span></div><formaction={formAction}style={{display: 'flex',gap: '10px',alignItems: 'center'}}><buttontype="submit"style={{padding: '10px'}}disabled={formPending}>TriggeruseFormState{formPending ? '(pending...)' : ''}</button><span>Count: {formState.count}</span></form><divonClick={()=>setState(Math.random())}style={{backgroundColor: 'red',padding: '10px',cursor: 'pointer'}}>State: {state}</div></div></>);}

@react-sizebot

react-sizebot commented Nov 13, 2025

Copy link
Copy Markdown

Comparing: 4a3d993...6a9c7bd

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name+/-BaseCurrent+/- gzipBase gzipCurrent gzip
oss-stable/react-dom/cjs/react-dom.production.js=6.84 kB6.84 kB+0.05%1.88 kB1.88 kB
oss-stable/react-dom/cjs/react-dom-client.production.js=608.03 kB608.03 kB=107.61 kB107.61 kB
oss-experimental/react-dom/cjs/react-dom.production.js=6.84 kB6.84 kB=1.88 kB1.88 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js=667.26 kB667.26 kB=117.51 kB117.51 kB
facebook-www/ReactDOM-prod.classic.js=693.38 kB693.38 kB=122.00 kB122.00 kB
facebook-www/ReactDOM-prod.modern.js=683.76 kB683.76 kB=120.40 kB120.40 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name+/-BaseCurrent+/- gzipBase gzipCurrent gzip
oss-experimental/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-experimental/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB

Generated by 🚫 dangerJS against 6a9c7bd

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

@hoxyq I may need your help... Two tests are failing and I don't understand what's wrong

  • profilingCache-test.js › should properly detect changed hooks
  • profilingCache-test.js › should detect context changes or lack of changes with conditional use()

They only fail when I call:

constprevHooks=inspectHooks(prevFiber);constnextHooks=inspectHooks(nextFiber);

I’ve narrowed it to this:

exportfunctioninspectHooks<Props>(renderFunction: (props: Props)=>React$Node,props: Props,currentDispatcher?: CurrentDispatcherRef,): HooksTree{if(currentDispatcher==null){currentDispatcher=ReactSharedInternals;}constpreviousDispatcher=currentDispatcher.H;currentDispatcher.H=DispatcherProxy;letreadHookLog;letancestorStackError;try{ancestorStackError=newError();renderFunction(props);}catch(error){handleRenderFunctionError(error);}finally{readHookLog=hookLog;hookLog=[];// $FlowFixMe[incompatible-use] found when upgrading FlowcurrentDispatcher.H=previousDispatcher;}constrootStack=ancestorStackError===undefined
? ([]: ParsedStackFrame[])
: ErrorStackParser.parse(ancestorStackError);returnbuildTree(rootStack,readHookLog);}

I suspect the issue comes from how inspectHooks temporarily swaps out the dispatcher. That dispatcher replacement might be overwriting or leaking some internal data, which then causes the change descriptions recorded by the Profiler to become incomplete.

@hoxyq
hoxyq self-requested a review November 19, 2025 19:24
@hoxyq

Copy link
Copy Markdown
Collaborator

Looking at the failed tests, it looks like the number of recorded changes doesn't match the number of performed updates. I don't see any early returns in the code, so I am not sure how this may actually happen.

I wonder if this could be related to the fact that we are actually calling render functions of components to build the hook tree.

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

FYI I'm looking into it again, maybe merging main would help 🤞

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

I can't run tests on the newest main due to this, is this a known problem or should I build differently? @hoxyq

@andreisilviudragnea

Copy link
Copy Markdown

Please have a look at #34427 too, maybe it's another solution to this issue @blazejkustra@hoxyq

@andreisilviudragnea

andreisilviudragnea commented Dec 25, 2025

Copy link
Copy Markdown

I think that merging #34547 without fixing the index counting error for all composite hooks just does not fix the initial problem at all, because having for example a useTransition hook before any other hook will invalidate the "Hook X changed" index for all hooks that follow useTransition

@blazejkustra

blazejkustra commented Dec 25, 2025

Copy link
Copy Markdown
ContributorAuthor

I think that merging #34547 without fixing the index counting error for all composite hooks just does not fix the initial problem at all, because having for example a useTransition hook before any other hook will invalidate the "Hook X changed" index for all hooks that follow useTransition

@andreisilviudragnea read through the discussion on my previous PR, especially this comment. The plan was to merge it and fix other hooks in follow up PRs. However, it turned out to be harder than expected and I haven't figure it out yet 🫠

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

It's a Christmas miracle!! 🎄

After some trial and error I finally realized that due to how inspectHooks() works the dispatcher was mocked for a moment, which overwrote the dispatch/setState variables the tests were storing 'globally'. I fixed it by saving references before they get overwritten.

Back to you @hoxyq 😄

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated
Comment on lines +2050 to +2054
// If the hook has subHooks, flatten them recursively
if (currentHook.subHooks && currentHook.subHooks.length > 0) {
flattened.push(...flattenHooksTree(currentHook.subHooks));
continue;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why this specific order is chosen?

This looks like subHooks wills have lower indexes in an array, when flattened. In the UI, the hook numbers (index + 1) usually reflect the tree structure. Basically indexOf(parent) < indexOf(parent.child) is always true.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flattenHooksTree function performs a depth-first traversal that only keeps leaf hooks (those without subHooks). This means custom hooks are unwrapped to primitive hooks.

For example, given this tree:

[
{ name: 'useCustomHook', subHooks: [
{ name: 'useState', value: 1 },
{ name: 'useEffect' }
]},
{ name: 'useState', value: 2 }
]

The flattened result is:

[
{ name: 'useState', value: 1 }, // index 0
{ name: 'useEffect' }, // index 1
{ name: 'useState', value: 2 } // index 2
]

This order matches React's hooks order, right? So comparing prevFlattened[i] with nextFlattened[i] identifies which primitive hooks changed between renders.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we were to change the traversal order the indexes wouldn't be correct I believe, wdyt @hoxyq?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying. I was confused a bit, but that is not an issue, since we only assign indexes to built-in hooks, which should be leaf nodes by definition.

In your example above, I believe this will be the result of flattening:

[
{ name: 'State', value: 1 }, // index 0
{ name: 'Effect' }, // index 1
{ name: 'CustomHook' }, { name: 'State', value: 2 } // index 2
]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it still makes sense to do flattening? We could probably just do dfs on both hook trees at the same time.

@blazejkustrablazejkustraJan 14, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think custom hook would not be there since it is omitted by the continue statement:

if(currentHook.subHooks&&currentHook.subHooks.length>0){flattened.push(...flattenHooksTree(currentHook.subHooks));continue;}

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably just do dfs on both hook trees at the same time.

Ahaa, so instead of building prevFlattened and nextFlattened I could do it in place? Good idea, let me try it 👀

@hoxyq

Copy link
Copy Markdown
Collaborator

These changes make sense, thank you for improving this. I left some clarifying questions.

Could you please also add some tests that will validate these changes? For example, the different (and correct) result of inspectHooks() calls when a complex component with useSyncExternalStore() or other hooks is used.

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

Back to you @hoxyq! I adjusted the code so it traverses in place and added a test as asked 🚀

I tried to run this new test on main and it failed miserably as expected :)

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated

@hoxyqhoxyq left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, please see my suggestion on making iterating over hook trees without optionality.

I can merge after it. We've accumulated some fixes that are not released yet, so I might release new patch / minor release later this month, but no concrete plans yet.

Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

I added your suggestions, seems that everything works well. Thank you!

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated
Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
@hoxyq
hoxyq merged commit 53daaf5 into react:mainJan 15, 2026
234 checks passed
@blazejkustra

blazejkustra commented Jan 15, 2026

Copy link
Copy Markdown
ContributorAuthor

I was wrong lol, good catch!

mdm317 added a commit to mdm317/react that referenced this pull request Apr 20, 2026
inspectHooksOfFiber is too expensive for this use case. On frequently updating
components, it can become slow enough to freeze the browser.
Revert hook change detection to the pre-react#35123 behavior. Then defer parsing
hook metadata, such as hook names, until after the commit completes, and add
caching so hook inspection runs at most once per fiber whenever possible.
For now, special handling is only implemented for SyncExternalStore,
Transition, ActionState, and FormState.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[DevTools Bug]: React Profiler reports higher hook numbers than shown in Components

4 participants

@blazejkustra@react-sizebot@hoxyq@andreisilviudragnea
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Improve the detection of changed hooks by blazejkustra · Pull Request #35123 · react/react · GitHub
Skip to content

Improve the detection of changed hooks - #35123

Merged
hoxyq merged 17 commits into
react:mainfrom
blazejkustra:fix/devtools-hook-indexes-mismatch
Jan 15, 2026
Merged

Improve the detection of changed hooks#35123
hoxyq merged 17 commits into
react:mainfrom
blazejkustra:fix/devtools-hook-indexes-mismatch

Conversation

@blazejkustra

@blazejkustrablazejkustra commented Nov 13, 2025

Copy link
Copy Markdown
Contributor

Summary

cc @hoxyq

Fixes#28584. Follow up to PR: #34547

This PR updates getChangedHooksIndices to account for the fact that useSyncExternalStore, useTransition, useActionState, useFormState internally mounts more than one hook while DevTools should treat it as a single user-facing hook.

Approach idea came from this comment 😄

Before:

QuickTime.movie.2.mov

After:

QuickTime.movie.mov

How did you test this change?

I used this component to reproduce this issue locally (I followed instructions in packages/react-devtools/CONTRIBUTING.md).

Details
import*asReactfrom'react';functionuseDeepNestedHook(){React.useState(0);// 1returnReact.useState(1);// 2}functionuseNestedHook(){constdeepState=useDeepNestedHook();React.useState(2);// 3React.useState(3);// 4returndeepState;}// Create a simple store for useSyncExternalStorefunctioncreateStore(initialValue){letvalue=initialValue;constlisteners=newSet();return{getSnapshot: ()=>value,subscribe: listener=>{listeners.add(listener);return()=>{listeners.delete(listener);};},update: newValue=>{value=newValue;listeners.forEach(listener=>listener());},};}constsyncExternalStore=createStore(0);exportdefaultfunctionInspectableElements(): React.Node{const[nestedState,setNestedState]=useNestedHook();// 5constsyncExternalValue=React.useSyncExternalStore(syncExternalStore.subscribe,syncExternalStore.getSnapshot,);// 6const[isPending,startTransition]=React.useTransition();// 7const[formState,formAction,formPending]=React.useActionState(async(prevState,formData)=>{return{count: (prevState?.count||0)+1};},{count: 0},);consthandleTransition=()=>{startTransition(()=>{setState(Math.random());});};// 8const[state,setState]=React.useState('test');return(<><divstyle={{padding: '20px',display: 'flex',flexDirection: 'column',gap: '10px',}}><divonClick={()=>setNestedState(Math.random())}style={{backgroundColor: 'red',padding: '10px',cursor: 'pointer'}}>State: {nestedState}</div><buttononClick={handleTransition}style={{padding: '10px'}}>TriggerTransition{isPending ? '(pending...)' : ''}</button><divstyle={{display: 'flex',gap: '10px',alignItems: 'center'}}><buttononClick={()=>syncExternalStore.update(syncExternalValue+1)}style={{padding: '10px'}}>TriggeruseSyncExternalStore</button><span>Value: {syncExternalValue}</span></div><formaction={formAction}style={{display: 'flex',gap: '10px',alignItems: 'center'}}><buttontype="submit"style={{padding: '10px'}}disabled={formPending}>TriggeruseFormState{formPending ? '(pending...)' : ''}</button><span>Count: {formState.count}</span></form><divonClick={()=>setState(Math.random())}style={{backgroundColor: 'red',padding: '10px',cursor: 'pointer'}}>State: {state}</div></div></>);}

@react-sizebot

react-sizebot commented Nov 13, 2025

Copy link
Copy Markdown

Comparing: 4a3d993...6a9c7bd

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name+/-BaseCurrent+/- gzipBase gzipCurrent gzip
oss-stable/react-dom/cjs/react-dom.production.js=6.84 kB6.84 kB+0.05%1.88 kB1.88 kB
oss-stable/react-dom/cjs/react-dom-client.production.js=608.03 kB608.03 kB=107.61 kB107.61 kB
oss-experimental/react-dom/cjs/react-dom.production.js=6.84 kB6.84 kB=1.88 kB1.88 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js=667.26 kB667.26 kB=117.51 kB117.51 kB
facebook-www/ReactDOM-prod.classic.js=693.38 kB693.38 kB=122.00 kB122.00 kB
facebook-www/ReactDOM-prod.modern.js=683.76 kB683.76 kB=120.40 kB120.40 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name+/-BaseCurrent+/- gzipBase gzipCurrent gzip
oss-experimental/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-experimental/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB

Generated by 🚫 dangerJS against 6a9c7bd

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

@hoxyq I may need your help... Two tests are failing and I don't understand what's wrong

  • profilingCache-test.js › should properly detect changed hooks
  • profilingCache-test.js › should detect context changes or lack of changes with conditional use()

They only fail when I call:

constprevHooks=inspectHooks(prevFiber);constnextHooks=inspectHooks(nextFiber);

I’ve narrowed it to this:

exportfunctioninspectHooks<Props>(renderFunction: (props: Props)=>React$Node,props: Props,currentDispatcher?: CurrentDispatcherRef,): HooksTree{if(currentDispatcher==null){currentDispatcher=ReactSharedInternals;}constpreviousDispatcher=currentDispatcher.H;currentDispatcher.H=DispatcherProxy;letreadHookLog;letancestorStackError;try{ancestorStackError=newError();renderFunction(props);}catch(error){handleRenderFunctionError(error);}finally{readHookLog=hookLog;hookLog=[];// $FlowFixMe[incompatible-use] found when upgrading FlowcurrentDispatcher.H=previousDispatcher;}constrootStack=ancestorStackError===undefined
? ([]: ParsedStackFrame[])
: ErrorStackParser.parse(ancestorStackError);returnbuildTree(rootStack,readHookLog);}

I suspect the issue comes from how inspectHooks temporarily swaps out the dispatcher. That dispatcher replacement might be overwriting or leaking some internal data, which then causes the change descriptions recorded by the Profiler to become incomplete.

@hoxyq
hoxyq self-requested a review November 19, 2025 19:24
@hoxyq

Copy link
Copy Markdown
Collaborator

Looking at the failed tests, it looks like the number of recorded changes doesn't match the number of performed updates. I don't see any early returns in the code, so I am not sure how this may actually happen.

I wonder if this could be related to the fact that we are actually calling render functions of components to build the hook tree.

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

FYI I'm looking into it again, maybe merging main would help 🤞

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

I can't run tests on the newest main due to this, is this a known problem or should I build differently? @hoxyq

@andreisilviudragnea

Copy link
Copy Markdown

Please have a look at #34427 too, maybe it's another solution to this issue @blazejkustra@hoxyq

@andreisilviudragnea

andreisilviudragnea commented Dec 25, 2025

Copy link
Copy Markdown

I think that merging #34547 without fixing the index counting error for all composite hooks just does not fix the initial problem at all, because having for example a useTransition hook before any other hook will invalidate the "Hook X changed" index for all hooks that follow useTransition

@blazejkustra

blazejkustra commented Dec 25, 2025

Copy link
Copy Markdown
ContributorAuthor

I think that merging #34547 without fixing the index counting error for all composite hooks just does not fix the initial problem at all, because having for example a useTransition hook before any other hook will invalidate the "Hook X changed" index for all hooks that follow useTransition

@andreisilviudragnea read through the discussion on my previous PR, especially this comment. The plan was to merge it and fix other hooks in follow up PRs. However, it turned out to be harder than expected and I haven't figure it out yet 🫠

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

It's a Christmas miracle!! 🎄

After some trial and error I finally realized that due to how inspectHooks() works the dispatcher was mocked for a moment, which overwrote the dispatch/setState variables the tests were storing 'globally'. I fixed it by saving references before they get overwritten.

Back to you @hoxyq 😄

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated
Comment on lines +2050 to +2054
// If the hook has subHooks, flatten them recursively
if (currentHook.subHooks && currentHook.subHooks.length > 0) {
flattened.push(...flattenHooksTree(currentHook.subHooks));
continue;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why this specific order is chosen?

This looks like subHooks wills have lower indexes in an array, when flattened. In the UI, the hook numbers (index + 1) usually reflect the tree structure. Basically indexOf(parent) < indexOf(parent.child) is always true.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flattenHooksTree function performs a depth-first traversal that only keeps leaf hooks (those without subHooks). This means custom hooks are unwrapped to primitive hooks.

For example, given this tree:

[
{ name: 'useCustomHook', subHooks: [
{ name: 'useState', value: 1 },
{ name: 'useEffect' }
]},
{ name: 'useState', value: 2 }
]

The flattened result is:

[
{ name: 'useState', value: 1 }, // index 0
{ name: 'useEffect' }, // index 1
{ name: 'useState', value: 2 } // index 2
]

This order matches React's hooks order, right? So comparing prevFlattened[i] with nextFlattened[i] identifies which primitive hooks changed between renders.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we were to change the traversal order the indexes wouldn't be correct I believe, wdyt @hoxyq?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying. I was confused a bit, but that is not an issue, since we only assign indexes to built-in hooks, which should be leaf nodes by definition.

In your example above, I believe this will be the result of flattening:

[
{ name: 'State', value: 1 }, // index 0
{ name: 'Effect' }, // index 1
{ name: 'CustomHook' }, { name: 'State', value: 2 } // index 2
]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it still makes sense to do flattening? We could probably just do dfs on both hook trees at the same time.

@blazejkustrablazejkustraJan 14, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think custom hook would not be there since it is omitted by the continue statement:

if(currentHook.subHooks&&currentHook.subHooks.length>0){flattened.push(...flattenHooksTree(currentHook.subHooks));continue;}

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably just do dfs on both hook trees at the same time.

Ahaa, so instead of building prevFlattened and nextFlattened I could do it in place? Good idea, let me try it 👀

@hoxyq

Copy link
Copy Markdown
Collaborator

These changes make sense, thank you for improving this. I left some clarifying questions.

Could you please also add some tests that will validate these changes? For example, the different (and correct) result of inspectHooks() calls when a complex component with useSyncExternalStore() or other hooks is used.

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

Back to you @hoxyq! I adjusted the code so it traverses in place and added a test as asked 🚀

I tried to run this new test on main and it failed miserably as expected :)

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated

@hoxyqhoxyq left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, please see my suggestion on making iterating over hook trees without optionality.

I can merge after it. We've accumulated some fixes that are not released yet, so I might release new patch / minor release later this month, but no concrete plans yet.

Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

I added your suggestions, seems that everything works well. Thank you!

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated
Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
@hoxyq
hoxyq merged commit 53daaf5 into react:mainJan 15, 2026
234 checks passed
@blazejkustra

blazejkustra commented Jan 15, 2026

Copy link
Copy Markdown
ContributorAuthor

I was wrong lol, good catch!

mdm317 added a commit to mdm317/react that referenced this pull request Apr 20, 2026
inspectHooksOfFiber is too expensive for this use case. On frequently updating
components, it can become slow enough to freeze the browser.
Revert hook change detection to the pre-react#35123 behavior. Then defer parsing
hook metadata, such as hook names, until after the commit completes, and add
caching so hook inspection runs at most once per fiber whenever possible.
For now, special handling is only implemented for SyncExternalStore,
Transition, ActionState, and FormState.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[DevTools Bug]: React Profiler reports higher hook numbers than shown in Components

4 participants

@blazejkustra@react-sizebot@hoxyq@andreisilviudragnea
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Improve the detection of changed hooks by blazejkustra · Pull Request #35123 · react/react · GitHub
Skip to content

Improve the detection of changed hooks - #35123

Merged
hoxyq merged 17 commits into
react:mainfrom
blazejkustra:fix/devtools-hook-indexes-mismatch
Jan 15, 2026
Merged

Improve the detection of changed hooks#35123
hoxyq merged 17 commits into
react:mainfrom
blazejkustra:fix/devtools-hook-indexes-mismatch

Conversation

@blazejkustra

@blazejkustrablazejkustra commented Nov 13, 2025

Copy link
Copy Markdown
Contributor

Summary

cc @hoxyq

Fixes#28584. Follow up to PR: #34547

This PR updates getChangedHooksIndices to account for the fact that useSyncExternalStore, useTransition, useActionState, useFormState internally mounts more than one hook while DevTools should treat it as a single user-facing hook.

Approach idea came from this comment 😄

Before:

QuickTime.movie.2.mov

After:

QuickTime.movie.mov

How did you test this change?

I used this component to reproduce this issue locally (I followed instructions in packages/react-devtools/CONTRIBUTING.md).

Details
import*asReactfrom'react';functionuseDeepNestedHook(){React.useState(0);// 1returnReact.useState(1);// 2}functionuseNestedHook(){constdeepState=useDeepNestedHook();React.useState(2);// 3React.useState(3);// 4returndeepState;}// Create a simple store for useSyncExternalStorefunctioncreateStore(initialValue){letvalue=initialValue;constlisteners=newSet();return{getSnapshot: ()=>value,subscribe: listener=>{listeners.add(listener);return()=>{listeners.delete(listener);};},update: newValue=>{value=newValue;listeners.forEach(listener=>listener());},};}constsyncExternalStore=createStore(0);exportdefaultfunctionInspectableElements(): React.Node{const[nestedState,setNestedState]=useNestedHook();// 5constsyncExternalValue=React.useSyncExternalStore(syncExternalStore.subscribe,syncExternalStore.getSnapshot,);// 6const[isPending,startTransition]=React.useTransition();// 7const[formState,formAction,formPending]=React.useActionState(async(prevState,formData)=>{return{count: (prevState?.count||0)+1};},{count: 0},);consthandleTransition=()=>{startTransition(()=>{setState(Math.random());});};// 8const[state,setState]=React.useState('test');return(<><divstyle={{padding: '20px',display: 'flex',flexDirection: 'column',gap: '10px',}}><divonClick={()=>setNestedState(Math.random())}style={{backgroundColor: 'red',padding: '10px',cursor: 'pointer'}}>State: {nestedState}</div><buttononClick={handleTransition}style={{padding: '10px'}}>TriggerTransition{isPending ? '(pending...)' : ''}</button><divstyle={{display: 'flex',gap: '10px',alignItems: 'center'}}><buttononClick={()=>syncExternalStore.update(syncExternalValue+1)}style={{padding: '10px'}}>TriggeruseSyncExternalStore</button><span>Value: {syncExternalValue}</span></div><formaction={formAction}style={{display: 'flex',gap: '10px',alignItems: 'center'}}><buttontype="submit"style={{padding: '10px'}}disabled={formPending}>TriggeruseFormState{formPending ? '(pending...)' : ''}</button><span>Count: {formState.count}</span></form><divonClick={()=>setState(Math.random())}style={{backgroundColor: 'red',padding: '10px',cursor: 'pointer'}}>State: {state}</div></div></>);}

@react-sizebot

react-sizebot commented Nov 13, 2025

Copy link
Copy Markdown

Comparing: 4a3d993...6a9c7bd

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name+/-BaseCurrent+/- gzipBase gzipCurrent gzip
oss-stable/react-dom/cjs/react-dom.production.js=6.84 kB6.84 kB+0.05%1.88 kB1.88 kB
oss-stable/react-dom/cjs/react-dom-client.production.js=608.03 kB608.03 kB=107.61 kB107.61 kB
oss-experimental/react-dom/cjs/react-dom.production.js=6.84 kB6.84 kB=1.88 kB1.88 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js=667.26 kB667.26 kB=117.51 kB117.51 kB
facebook-www/ReactDOM-prod.classic.js=693.38 kB693.38 kB=122.00 kB122.00 kB
facebook-www/ReactDOM-prod.modern.js=683.76 kB683.76 kB=120.40 kB120.40 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name+/-BaseCurrent+/- gzipBase gzipCurrent gzip
oss-experimental/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-experimental/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB

Generated by 🚫 dangerJS against 6a9c7bd

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

@hoxyq I may need your help... Two tests are failing and I don't understand what's wrong

  • profilingCache-test.js › should properly detect changed hooks
  • profilingCache-test.js › should detect context changes or lack of changes with conditional use()

They only fail when I call:

constprevHooks=inspectHooks(prevFiber);constnextHooks=inspectHooks(nextFiber);

I’ve narrowed it to this:

exportfunctioninspectHooks<Props>(renderFunction: (props: Props)=>React$Node,props: Props,currentDispatcher?: CurrentDispatcherRef,): HooksTree{if(currentDispatcher==null){currentDispatcher=ReactSharedInternals;}constpreviousDispatcher=currentDispatcher.H;currentDispatcher.H=DispatcherProxy;letreadHookLog;letancestorStackError;try{ancestorStackError=newError();renderFunction(props);}catch(error){handleRenderFunctionError(error);}finally{readHookLog=hookLog;hookLog=[];// $FlowFixMe[incompatible-use] found when upgrading FlowcurrentDispatcher.H=previousDispatcher;}constrootStack=ancestorStackError===undefined
? ([]: ParsedStackFrame[])
: ErrorStackParser.parse(ancestorStackError);returnbuildTree(rootStack,readHookLog);}

I suspect the issue comes from how inspectHooks temporarily swaps out the dispatcher. That dispatcher replacement might be overwriting or leaking some internal data, which then causes the change descriptions recorded by the Profiler to become incomplete.

@hoxyq
hoxyq self-requested a review November 19, 2025 19:24
@hoxyq

Copy link
Copy Markdown
Collaborator

Looking at the failed tests, it looks like the number of recorded changes doesn't match the number of performed updates. I don't see any early returns in the code, so I am not sure how this may actually happen.

I wonder if this could be related to the fact that we are actually calling render functions of components to build the hook tree.

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

FYI I'm looking into it again, maybe merging main would help 🤞

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

I can't run tests on the newest main due to this, is this a known problem or should I build differently? @hoxyq

@andreisilviudragnea

Copy link
Copy Markdown

Please have a look at #34427 too, maybe it's another solution to this issue @blazejkustra@hoxyq

@andreisilviudragnea

andreisilviudragnea commented Dec 25, 2025

Copy link
Copy Markdown

I think that merging #34547 without fixing the index counting error for all composite hooks just does not fix the initial problem at all, because having for example a useTransition hook before any other hook will invalidate the "Hook X changed" index for all hooks that follow useTransition

@blazejkustra

blazejkustra commented Dec 25, 2025

Copy link
Copy Markdown
ContributorAuthor

I think that merging #34547 without fixing the index counting error for all composite hooks just does not fix the initial problem at all, because having for example a useTransition hook before any other hook will invalidate the "Hook X changed" index for all hooks that follow useTransition

@andreisilviudragnea read through the discussion on my previous PR, especially this comment. The plan was to merge it and fix other hooks in follow up PRs. However, it turned out to be harder than expected and I haven't figure it out yet 🫠

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

It's a Christmas miracle!! 🎄

After some trial and error I finally realized that due to how inspectHooks() works the dispatcher was mocked for a moment, which overwrote the dispatch/setState variables the tests were storing 'globally'. I fixed it by saving references before they get overwritten.

Back to you @hoxyq 😄

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated
Comment on lines +2050 to +2054
// If the hook has subHooks, flatten them recursively
if (currentHook.subHooks && currentHook.subHooks.length > 0) {
flattened.push(...flattenHooksTree(currentHook.subHooks));
continue;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why this specific order is chosen?

This looks like subHooks wills have lower indexes in an array, when flattened. In the UI, the hook numbers (index + 1) usually reflect the tree structure. Basically indexOf(parent) < indexOf(parent.child) is always true.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flattenHooksTree function performs a depth-first traversal that only keeps leaf hooks (those without subHooks). This means custom hooks are unwrapped to primitive hooks.

For example, given this tree:

[
{ name: 'useCustomHook', subHooks: [
{ name: 'useState', value: 1 },
{ name: 'useEffect' }
]},
{ name: 'useState', value: 2 }
]

The flattened result is:

[
{ name: 'useState', value: 1 }, // index 0
{ name: 'useEffect' }, // index 1
{ name: 'useState', value: 2 } // index 2
]

This order matches React's hooks order, right? So comparing prevFlattened[i] with nextFlattened[i] identifies which primitive hooks changed between renders.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we were to change the traversal order the indexes wouldn't be correct I believe, wdyt @hoxyq?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying. I was confused a bit, but that is not an issue, since we only assign indexes to built-in hooks, which should be leaf nodes by definition.

In your example above, I believe this will be the result of flattening:

[
{ name: 'State', value: 1 }, // index 0
{ name: 'Effect' }, // index 1
{ name: 'CustomHook' }, { name: 'State', value: 2 } // index 2
]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it still makes sense to do flattening? We could probably just do dfs on both hook trees at the same time.

@blazejkustrablazejkustraJan 14, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think custom hook would not be there since it is omitted by the continue statement:

if(currentHook.subHooks&&currentHook.subHooks.length>0){flattened.push(...flattenHooksTree(currentHook.subHooks));continue;}

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably just do dfs on both hook trees at the same time.

Ahaa, so instead of building prevFlattened and nextFlattened I could do it in place? Good idea, let me try it 👀

@hoxyq

Copy link
Copy Markdown
Collaborator

These changes make sense, thank you for improving this. I left some clarifying questions.

Could you please also add some tests that will validate these changes? For example, the different (and correct) result of inspectHooks() calls when a complex component with useSyncExternalStore() or other hooks is used.

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

Back to you @hoxyq! I adjusted the code so it traverses in place and added a test as asked 🚀

I tried to run this new test on main and it failed miserably as expected :)

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated

@hoxyqhoxyq left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, please see my suggestion on making iterating over hook trees without optionality.

I can merge after it. We've accumulated some fixes that are not released yet, so I might release new patch / minor release later this month, but no concrete plans yet.

Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

I added your suggestions, seems that everything works well. Thank you!

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated
Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
@hoxyq
hoxyq merged commit 53daaf5 into react:mainJan 15, 2026
234 checks passed
@blazejkustra

blazejkustra commented Jan 15, 2026

Copy link
Copy Markdown
ContributorAuthor

I was wrong lol, good catch!

mdm317 added a commit to mdm317/react that referenced this pull request Apr 20, 2026
inspectHooksOfFiber is too expensive for this use case. On frequently updating
components, it can become slow enough to freeze the browser.
Revert hook change detection to the pre-react#35123 behavior. Then defer parsing
hook metadata, such as hook names, until after the commit completes, and add
caching so hook inspection runs at most once per fiber whenever possible.
For now, special handling is only implemented for SyncExternalStore,
Transition, ActionState, and FormState.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[DevTools Bug]: React Profiler reports higher hook numbers than shown in Components

4 participants

@blazejkustra@react-sizebot@hoxyq@andreisilviudragnea
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Improve the detection of changed hooks by blazejkustra · Pull Request #35123 · react/react · GitHub
Skip to content

Improve the detection of changed hooks - #35123

Merged
hoxyq merged 17 commits into
react:mainfrom
blazejkustra:fix/devtools-hook-indexes-mismatch
Jan 15, 2026
Merged

Improve the detection of changed hooks#35123
hoxyq merged 17 commits into
react:mainfrom
blazejkustra:fix/devtools-hook-indexes-mismatch

Conversation

@blazejkustra

@blazejkustrablazejkustra commented Nov 13, 2025

Copy link
Copy Markdown
Contributor

Summary

cc @hoxyq

Fixes#28584. Follow up to PR: #34547

This PR updates getChangedHooksIndices to account for the fact that useSyncExternalStore, useTransition, useActionState, useFormState internally mounts more than one hook while DevTools should treat it as a single user-facing hook.

Approach idea came from this comment 😄

Before:

QuickTime.movie.2.mov

After:

QuickTime.movie.mov

How did you test this change?

I used this component to reproduce this issue locally (I followed instructions in packages/react-devtools/CONTRIBUTING.md).

Details
import*asReactfrom'react';functionuseDeepNestedHook(){React.useState(0);// 1returnReact.useState(1);// 2}functionuseNestedHook(){constdeepState=useDeepNestedHook();React.useState(2);// 3React.useState(3);// 4returndeepState;}// Create a simple store for useSyncExternalStorefunctioncreateStore(initialValue){letvalue=initialValue;constlisteners=newSet();return{getSnapshot: ()=>value,subscribe: listener=>{listeners.add(listener);return()=>{listeners.delete(listener);};},update: newValue=>{value=newValue;listeners.forEach(listener=>listener());},};}constsyncExternalStore=createStore(0);exportdefaultfunctionInspectableElements(): React.Node{const[nestedState,setNestedState]=useNestedHook();// 5constsyncExternalValue=React.useSyncExternalStore(syncExternalStore.subscribe,syncExternalStore.getSnapshot,);// 6const[isPending,startTransition]=React.useTransition();// 7const[formState,formAction,formPending]=React.useActionState(async(prevState,formData)=>{return{count: (prevState?.count||0)+1};},{count: 0},);consthandleTransition=()=>{startTransition(()=>{setState(Math.random());});};// 8const[state,setState]=React.useState('test');return(<><divstyle={{padding: '20px',display: 'flex',flexDirection: 'column',gap: '10px',}}><divonClick={()=>setNestedState(Math.random())}style={{backgroundColor: 'red',padding: '10px',cursor: 'pointer'}}>State: {nestedState}</div><buttononClick={handleTransition}style={{padding: '10px'}}>TriggerTransition{isPending ? '(pending...)' : ''}</button><divstyle={{display: 'flex',gap: '10px',alignItems: 'center'}}><buttononClick={()=>syncExternalStore.update(syncExternalValue+1)}style={{padding: '10px'}}>TriggeruseSyncExternalStore</button><span>Value: {syncExternalValue}</span></div><formaction={formAction}style={{display: 'flex',gap: '10px',alignItems: 'center'}}><buttontype="submit"style={{padding: '10px'}}disabled={formPending}>TriggeruseFormState{formPending ? '(pending...)' : ''}</button><span>Count: {formState.count}</span></form><divonClick={()=>setState(Math.random())}style={{backgroundColor: 'red',padding: '10px',cursor: 'pointer'}}>State: {state}</div></div></>);}

@react-sizebot

react-sizebot commented Nov 13, 2025

Copy link
Copy Markdown

Comparing: 4a3d993...6a9c7bd

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name+/-BaseCurrent+/- gzipBase gzipCurrent gzip
oss-stable/react-dom/cjs/react-dom.production.js=6.84 kB6.84 kB+0.05%1.88 kB1.88 kB
oss-stable/react-dom/cjs/react-dom-client.production.js=608.03 kB608.03 kB=107.61 kB107.61 kB
oss-experimental/react-dom/cjs/react-dom.production.js=6.84 kB6.84 kB=1.88 kB1.88 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js=667.26 kB667.26 kB=117.51 kB117.51 kB
facebook-www/ReactDOM-prod.classic.js=693.38 kB693.38 kB=122.00 kB122.00 kB
facebook-www/ReactDOM-prod.modern.js=683.76 kB683.76 kB=120.40 kB120.40 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name+/-BaseCurrent+/- gzipBase gzipCurrent gzip
oss-experimental/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-experimental/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB

Generated by 🚫 dangerJS against 6a9c7bd

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

@hoxyq I may need your help... Two tests are failing and I don't understand what's wrong

  • profilingCache-test.js › should properly detect changed hooks
  • profilingCache-test.js › should detect context changes or lack of changes with conditional use()

They only fail when I call:

constprevHooks=inspectHooks(prevFiber);constnextHooks=inspectHooks(nextFiber);

I’ve narrowed it to this:

exportfunctioninspectHooks<Props>(renderFunction: (props: Props)=>React$Node,props: Props,currentDispatcher?: CurrentDispatcherRef,): HooksTree{if(currentDispatcher==null){currentDispatcher=ReactSharedInternals;}constpreviousDispatcher=currentDispatcher.H;currentDispatcher.H=DispatcherProxy;letreadHookLog;letancestorStackError;try{ancestorStackError=newError();renderFunction(props);}catch(error){handleRenderFunctionError(error);}finally{readHookLog=hookLog;hookLog=[];// $FlowFixMe[incompatible-use] found when upgrading FlowcurrentDispatcher.H=previousDispatcher;}constrootStack=ancestorStackError===undefined
? ([]: ParsedStackFrame[])
: ErrorStackParser.parse(ancestorStackError);returnbuildTree(rootStack,readHookLog);}

I suspect the issue comes from how inspectHooks temporarily swaps out the dispatcher. That dispatcher replacement might be overwriting or leaking some internal data, which then causes the change descriptions recorded by the Profiler to become incomplete.

@hoxyq
hoxyq self-requested a review November 19, 2025 19:24
@hoxyq

Copy link
Copy Markdown
Collaborator

Looking at the failed tests, it looks like the number of recorded changes doesn't match the number of performed updates. I don't see any early returns in the code, so I am not sure how this may actually happen.

I wonder if this could be related to the fact that we are actually calling render functions of components to build the hook tree.

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

FYI I'm looking into it again, maybe merging main would help 🤞

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

I can't run tests on the newest main due to this, is this a known problem or should I build differently? @hoxyq

@andreisilviudragnea

Copy link
Copy Markdown

Please have a look at #34427 too, maybe it's another solution to this issue @blazejkustra@hoxyq

@andreisilviudragnea

andreisilviudragnea commented Dec 25, 2025

Copy link
Copy Markdown

I think that merging #34547 without fixing the index counting error for all composite hooks just does not fix the initial problem at all, because having for example a useTransition hook before any other hook will invalidate the "Hook X changed" index for all hooks that follow useTransition

@blazejkustra

blazejkustra commented Dec 25, 2025

Copy link
Copy Markdown
ContributorAuthor

I think that merging #34547 without fixing the index counting error for all composite hooks just does not fix the initial problem at all, because having for example a useTransition hook before any other hook will invalidate the "Hook X changed" index for all hooks that follow useTransition

@andreisilviudragnea read through the discussion on my previous PR, especially this comment. The plan was to merge it and fix other hooks in follow up PRs. However, it turned out to be harder than expected and I haven't figure it out yet 🫠

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

It's a Christmas miracle!! 🎄

After some trial and error I finally realized that due to how inspectHooks() works the dispatcher was mocked for a moment, which overwrote the dispatch/setState variables the tests were storing 'globally'. I fixed it by saving references before they get overwritten.

Back to you @hoxyq 😄

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated
Comment on lines +2050 to +2054
// If the hook has subHooks, flatten them recursively
if (currentHook.subHooks && currentHook.subHooks.length > 0) {
flattened.push(...flattenHooksTree(currentHook.subHooks));
continue;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why this specific order is chosen?

This looks like subHooks wills have lower indexes in an array, when flattened. In the UI, the hook numbers (index + 1) usually reflect the tree structure. Basically indexOf(parent) < indexOf(parent.child) is always true.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flattenHooksTree function performs a depth-first traversal that only keeps leaf hooks (those without subHooks). This means custom hooks are unwrapped to primitive hooks.

For example, given this tree:

[
{ name: 'useCustomHook', subHooks: [
{ name: 'useState', value: 1 },
{ name: 'useEffect' }
]},
{ name: 'useState', value: 2 }
]

The flattened result is:

[
{ name: 'useState', value: 1 }, // index 0
{ name: 'useEffect' }, // index 1
{ name: 'useState', value: 2 } // index 2
]

This order matches React's hooks order, right? So comparing prevFlattened[i] with nextFlattened[i] identifies which primitive hooks changed between renders.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we were to change the traversal order the indexes wouldn't be correct I believe, wdyt @hoxyq?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying. I was confused a bit, but that is not an issue, since we only assign indexes to built-in hooks, which should be leaf nodes by definition.

In your example above, I believe this will be the result of flattening:

[
{ name: 'State', value: 1 }, // index 0
{ name: 'Effect' }, // index 1
{ name: 'CustomHook' }, { name: 'State', value: 2 } // index 2
]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it still makes sense to do flattening? We could probably just do dfs on both hook trees at the same time.

@blazejkustrablazejkustraJan 14, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think custom hook would not be there since it is omitted by the continue statement:

if(currentHook.subHooks&&currentHook.subHooks.length>0){flattened.push(...flattenHooksTree(currentHook.subHooks));continue;}

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably just do dfs on both hook trees at the same time.

Ahaa, so instead of building prevFlattened and nextFlattened I could do it in place? Good idea, let me try it 👀

@hoxyq

Copy link
Copy Markdown
Collaborator

These changes make sense, thank you for improving this. I left some clarifying questions.

Could you please also add some tests that will validate these changes? For example, the different (and correct) result of inspectHooks() calls when a complex component with useSyncExternalStore() or other hooks is used.

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

Back to you @hoxyq! I adjusted the code so it traverses in place and added a test as asked 🚀

I tried to run this new test on main and it failed miserably as expected :)

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated

@hoxyqhoxyq left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, please see my suggestion on making iterating over hook trees without optionality.

I can merge after it. We've accumulated some fixes that are not released yet, so I might release new patch / minor release later this month, but no concrete plans yet.

Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

I added your suggestions, seems that everything works well. Thank you!

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated
Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
@hoxyq
hoxyq merged commit 53daaf5 into react:mainJan 15, 2026
234 checks passed
@blazejkustra

blazejkustra commented Jan 15, 2026

Copy link
Copy Markdown
ContributorAuthor

I was wrong lol, good catch!

mdm317 added a commit to mdm317/react that referenced this pull request Apr 20, 2026
inspectHooksOfFiber is too expensive for this use case. On frequently updating
components, it can become slow enough to freeze the browser.
Revert hook change detection to the pre-react#35123 behavior. Then defer parsing
hook metadata, such as hook names, until after the commit completes, and add
caching so hook inspection runs at most once per fiber whenever possible.
For now, special handling is only implemented for SyncExternalStore,
Transition, ActionState, and FormState.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[DevTools Bug]: React Profiler reports higher hook numbers than shown in Components

4 participants

@blazejkustra@react-sizebot@hoxyq@andreisilviudragnea
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Improve the detection of changed hooks by blazejkustra · Pull Request #35123 · react/react · GitHub
Skip to content

Improve the detection of changed hooks - #35123

Merged
hoxyq merged 17 commits into
react:mainfrom
blazejkustra:fix/devtools-hook-indexes-mismatch
Jan 15, 2026
Merged

Improve the detection of changed hooks#35123
hoxyq merged 17 commits into
react:mainfrom
blazejkustra:fix/devtools-hook-indexes-mismatch

Conversation

@blazejkustra

@blazejkustrablazejkustra commented Nov 13, 2025

Copy link
Copy Markdown
Contributor

Summary

cc @hoxyq

Fixes#28584. Follow up to PR: #34547

This PR updates getChangedHooksIndices to account for the fact that useSyncExternalStore, useTransition, useActionState, useFormState internally mounts more than one hook while DevTools should treat it as a single user-facing hook.

Approach idea came from this comment 😄

Before:

QuickTime.movie.2.mov

After:

QuickTime.movie.mov

How did you test this change?

I used this component to reproduce this issue locally (I followed instructions in packages/react-devtools/CONTRIBUTING.md).

Details
import*asReactfrom'react';functionuseDeepNestedHook(){React.useState(0);// 1returnReact.useState(1);// 2}functionuseNestedHook(){constdeepState=useDeepNestedHook();React.useState(2);// 3React.useState(3);// 4returndeepState;}// Create a simple store for useSyncExternalStorefunctioncreateStore(initialValue){letvalue=initialValue;constlisteners=newSet();return{getSnapshot: ()=>value,subscribe: listener=>{listeners.add(listener);return()=>{listeners.delete(listener);};},update: newValue=>{value=newValue;listeners.forEach(listener=>listener());},};}constsyncExternalStore=createStore(0);exportdefaultfunctionInspectableElements(): React.Node{const[nestedState,setNestedState]=useNestedHook();// 5constsyncExternalValue=React.useSyncExternalStore(syncExternalStore.subscribe,syncExternalStore.getSnapshot,);// 6const[isPending,startTransition]=React.useTransition();// 7const[formState,formAction,formPending]=React.useActionState(async(prevState,formData)=>{return{count: (prevState?.count||0)+1};},{count: 0},);consthandleTransition=()=>{startTransition(()=>{setState(Math.random());});};// 8const[state,setState]=React.useState('test');return(<><divstyle={{padding: '20px',display: 'flex',flexDirection: 'column',gap: '10px',}}><divonClick={()=>setNestedState(Math.random())}style={{backgroundColor: 'red',padding: '10px',cursor: 'pointer'}}>State: {nestedState}</div><buttononClick={handleTransition}style={{padding: '10px'}}>TriggerTransition{isPending ? '(pending...)' : ''}</button><divstyle={{display: 'flex',gap: '10px',alignItems: 'center'}}><buttononClick={()=>syncExternalStore.update(syncExternalValue+1)}style={{padding: '10px'}}>TriggeruseSyncExternalStore</button><span>Value: {syncExternalValue}</span></div><formaction={formAction}style={{display: 'flex',gap: '10px',alignItems: 'center'}}><buttontype="submit"style={{padding: '10px'}}disabled={formPending}>TriggeruseFormState{formPending ? '(pending...)' : ''}</button><span>Count: {formState.count}</span></form><divonClick={()=>setState(Math.random())}style={{backgroundColor: 'red',padding: '10px',cursor: 'pointer'}}>State: {state}</div></div></>);}

@react-sizebot

react-sizebot commented Nov 13, 2025

Copy link
Copy Markdown

Comparing: 4a3d993...6a9c7bd

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name+/-BaseCurrent+/- gzipBase gzipCurrent gzip
oss-stable/react-dom/cjs/react-dom.production.js=6.84 kB6.84 kB+0.05%1.88 kB1.88 kB
oss-stable/react-dom/cjs/react-dom-client.production.js=608.03 kB608.03 kB=107.61 kB107.61 kB
oss-experimental/react-dom/cjs/react-dom.production.js=6.84 kB6.84 kB=1.88 kB1.88 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js=667.26 kB667.26 kB=117.51 kB117.51 kB
facebook-www/ReactDOM-prod.classic.js=693.38 kB693.38 kB=122.00 kB122.00 kB
facebook-www/ReactDOM-prod.modern.js=683.76 kB683.76 kB=120.40 kB120.40 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name+/-BaseCurrent+/- gzipBase gzipCurrent gzip
oss-experimental/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-experimental/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB

Generated by 🚫 dangerJS against 6a9c7bd

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

@hoxyq I may need your help... Two tests are failing and I don't understand what's wrong

  • profilingCache-test.js › should properly detect changed hooks
  • profilingCache-test.js › should detect context changes or lack of changes with conditional use()

They only fail when I call:

constprevHooks=inspectHooks(prevFiber);constnextHooks=inspectHooks(nextFiber);

I’ve narrowed it to this:

exportfunctioninspectHooks<Props>(renderFunction: (props: Props)=>React$Node,props: Props,currentDispatcher?: CurrentDispatcherRef,): HooksTree{if(currentDispatcher==null){currentDispatcher=ReactSharedInternals;}constpreviousDispatcher=currentDispatcher.H;currentDispatcher.H=DispatcherProxy;letreadHookLog;letancestorStackError;try{ancestorStackError=newError();renderFunction(props);}catch(error){handleRenderFunctionError(error);}finally{readHookLog=hookLog;hookLog=[];// $FlowFixMe[incompatible-use] found when upgrading FlowcurrentDispatcher.H=previousDispatcher;}constrootStack=ancestorStackError===undefined
? ([]: ParsedStackFrame[])
: ErrorStackParser.parse(ancestorStackError);returnbuildTree(rootStack,readHookLog);}

I suspect the issue comes from how inspectHooks temporarily swaps out the dispatcher. That dispatcher replacement might be overwriting or leaking some internal data, which then causes the change descriptions recorded by the Profiler to become incomplete.

@hoxyq
hoxyq self-requested a review November 19, 2025 19:24
@hoxyq

Copy link
Copy Markdown
Collaborator

Looking at the failed tests, it looks like the number of recorded changes doesn't match the number of performed updates. I don't see any early returns in the code, so I am not sure how this may actually happen.

I wonder if this could be related to the fact that we are actually calling render functions of components to build the hook tree.

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

FYI I'm looking into it again, maybe merging main would help 🤞

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

I can't run tests on the newest main due to this, is this a known problem or should I build differently? @hoxyq

@andreisilviudragnea

Copy link
Copy Markdown

Please have a look at #34427 too, maybe it's another solution to this issue @blazejkustra@hoxyq

@andreisilviudragnea

andreisilviudragnea commented Dec 25, 2025

Copy link
Copy Markdown

I think that merging #34547 without fixing the index counting error for all composite hooks just does not fix the initial problem at all, because having for example a useTransition hook before any other hook will invalidate the "Hook X changed" index for all hooks that follow useTransition

@blazejkustra

blazejkustra commented Dec 25, 2025

Copy link
Copy Markdown
ContributorAuthor

I think that merging #34547 without fixing the index counting error for all composite hooks just does not fix the initial problem at all, because having for example a useTransition hook before any other hook will invalidate the "Hook X changed" index for all hooks that follow useTransition

@andreisilviudragnea read through the discussion on my previous PR, especially this comment. The plan was to merge it and fix other hooks in follow up PRs. However, it turned out to be harder than expected and I haven't figure it out yet 🫠

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

It's a Christmas miracle!! 🎄

After some trial and error I finally realized that due to how inspectHooks() works the dispatcher was mocked for a moment, which overwrote the dispatch/setState variables the tests were storing 'globally'. I fixed it by saving references before they get overwritten.

Back to you @hoxyq 😄

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated
Comment on lines +2050 to +2054
// If the hook has subHooks, flatten them recursively
if (currentHook.subHooks && currentHook.subHooks.length > 0) {
flattened.push(...flattenHooksTree(currentHook.subHooks));
continue;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why this specific order is chosen?

This looks like subHooks wills have lower indexes in an array, when flattened. In the UI, the hook numbers (index + 1) usually reflect the tree structure. Basically indexOf(parent) < indexOf(parent.child) is always true.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flattenHooksTree function performs a depth-first traversal that only keeps leaf hooks (those without subHooks). This means custom hooks are unwrapped to primitive hooks.

For example, given this tree:

[
{ name: 'useCustomHook', subHooks: [
{ name: 'useState', value: 1 },
{ name: 'useEffect' }
]},
{ name: 'useState', value: 2 }
]

The flattened result is:

[
{ name: 'useState', value: 1 }, // index 0
{ name: 'useEffect' }, // index 1
{ name: 'useState', value: 2 } // index 2
]

This order matches React's hooks order, right? So comparing prevFlattened[i] with nextFlattened[i] identifies which primitive hooks changed between renders.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we were to change the traversal order the indexes wouldn't be correct I believe, wdyt @hoxyq?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying. I was confused a bit, but that is not an issue, since we only assign indexes to built-in hooks, which should be leaf nodes by definition.

In your example above, I believe this will be the result of flattening:

[
{ name: 'State', value: 1 }, // index 0
{ name: 'Effect' }, // index 1
{ name: 'CustomHook' }, { name: 'State', value: 2 } // index 2
]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it still makes sense to do flattening? We could probably just do dfs on both hook trees at the same time.

@blazejkustrablazejkustraJan 14, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think custom hook would not be there since it is omitted by the continue statement:

if(currentHook.subHooks&&currentHook.subHooks.length>0){flattened.push(...flattenHooksTree(currentHook.subHooks));continue;}

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably just do dfs on both hook trees at the same time.

Ahaa, so instead of building prevFlattened and nextFlattened I could do it in place? Good idea, let me try it 👀

@hoxyq

Copy link
Copy Markdown
Collaborator

These changes make sense, thank you for improving this. I left some clarifying questions.

Could you please also add some tests that will validate these changes? For example, the different (and correct) result of inspectHooks() calls when a complex component with useSyncExternalStore() or other hooks is used.

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

Back to you @hoxyq! I adjusted the code so it traverses in place and added a test as asked 🚀

I tried to run this new test on main and it failed miserably as expected :)

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated

@hoxyqhoxyq left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, please see my suggestion on making iterating over hook trees without optionality.

I can merge after it. We've accumulated some fixes that are not released yet, so I might release new patch / minor release later this month, but no concrete plans yet.

Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

I added your suggestions, seems that everything works well. Thank you!

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated
Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
@hoxyq
hoxyq merged commit 53daaf5 into react:mainJan 15, 2026
234 checks passed
@blazejkustra

blazejkustra commented Jan 15, 2026

Copy link
Copy Markdown
ContributorAuthor

I was wrong lol, good catch!

mdm317 added a commit to mdm317/react that referenced this pull request Apr 20, 2026
inspectHooksOfFiber is too expensive for this use case. On frequently updating
components, it can become slow enough to freeze the browser.
Revert hook change detection to the pre-react#35123 behavior. Then defer parsing
hook metadata, such as hook names, until after the commit completes, and add
caching so hook inspection runs at most once per fiber whenever possible.
For now, special handling is only implemented for SyncExternalStore,
Transition, ActionState, and FormState.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[DevTools Bug]: React Profiler reports higher hook numbers than shown in Components

4 participants

@blazejkustra@react-sizebot@hoxyq@andreisilviudragnea
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Improve the detection of changed hooks by blazejkustra · Pull Request #35123 · react/react · GitHub
Skip to content

Improve the detection of changed hooks - #35123

Merged
hoxyq merged 17 commits into
react:mainfrom
blazejkustra:fix/devtools-hook-indexes-mismatch
Jan 15, 2026
Merged

Improve the detection of changed hooks#35123
hoxyq merged 17 commits into
react:mainfrom
blazejkustra:fix/devtools-hook-indexes-mismatch

Conversation

@blazejkustra

@blazejkustrablazejkustra commented Nov 13, 2025

Copy link
Copy Markdown
Contributor

Summary

cc @hoxyq

Fixes#28584. Follow up to PR: #34547

This PR updates getChangedHooksIndices to account for the fact that useSyncExternalStore, useTransition, useActionState, useFormState internally mounts more than one hook while DevTools should treat it as a single user-facing hook.

Approach idea came from this comment 😄

Before:

QuickTime.movie.2.mov

After:

QuickTime.movie.mov

How did you test this change?

I used this component to reproduce this issue locally (I followed instructions in packages/react-devtools/CONTRIBUTING.md).

Details
import*asReactfrom'react';functionuseDeepNestedHook(){React.useState(0);// 1returnReact.useState(1);// 2}functionuseNestedHook(){constdeepState=useDeepNestedHook();React.useState(2);// 3React.useState(3);// 4returndeepState;}// Create a simple store for useSyncExternalStorefunctioncreateStore(initialValue){letvalue=initialValue;constlisteners=newSet();return{getSnapshot: ()=>value,subscribe: listener=>{listeners.add(listener);return()=>{listeners.delete(listener);};},update: newValue=>{value=newValue;listeners.forEach(listener=>listener());},};}constsyncExternalStore=createStore(0);exportdefaultfunctionInspectableElements(): React.Node{const[nestedState,setNestedState]=useNestedHook();// 5constsyncExternalValue=React.useSyncExternalStore(syncExternalStore.subscribe,syncExternalStore.getSnapshot,);// 6const[isPending,startTransition]=React.useTransition();// 7const[formState,formAction,formPending]=React.useActionState(async(prevState,formData)=>{return{count: (prevState?.count||0)+1};},{count: 0},);consthandleTransition=()=>{startTransition(()=>{setState(Math.random());});};// 8const[state,setState]=React.useState('test');return(<><divstyle={{padding: '20px',display: 'flex',flexDirection: 'column',gap: '10px',}}><divonClick={()=>setNestedState(Math.random())}style={{backgroundColor: 'red',padding: '10px',cursor: 'pointer'}}>State: {nestedState}</div><buttononClick={handleTransition}style={{padding: '10px'}}>TriggerTransition{isPending ? '(pending...)' : ''}</button><divstyle={{display: 'flex',gap: '10px',alignItems: 'center'}}><buttononClick={()=>syncExternalStore.update(syncExternalValue+1)}style={{padding: '10px'}}>TriggeruseSyncExternalStore</button><span>Value: {syncExternalValue}</span></div><formaction={formAction}style={{display: 'flex',gap: '10px',alignItems: 'center'}}><buttontype="submit"style={{padding: '10px'}}disabled={formPending}>TriggeruseFormState{formPending ? '(pending...)' : ''}</button><span>Count: {formState.count}</span></form><divonClick={()=>setState(Math.random())}style={{backgroundColor: 'red',padding: '10px',cursor: 'pointer'}}>State: {state}</div></div></>);}

@react-sizebot

react-sizebot commented Nov 13, 2025

Copy link
Copy Markdown

Comparing: 4a3d993...6a9c7bd

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name+/-BaseCurrent+/- gzipBase gzipCurrent gzip
oss-stable/react-dom/cjs/react-dom.production.js=6.84 kB6.84 kB+0.05%1.88 kB1.88 kB
oss-stable/react-dom/cjs/react-dom-client.production.js=608.03 kB608.03 kB=107.61 kB107.61 kB
oss-experimental/react-dom/cjs/react-dom.production.js=6.84 kB6.84 kB=1.88 kB1.88 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js=667.26 kB667.26 kB=117.51 kB117.51 kB
facebook-www/ReactDOM-prod.classic.js=693.38 kB693.38 kB=122.00 kB122.00 kB
facebook-www/ReactDOM-prod.modern.js=683.76 kB683.76 kB=120.40 kB120.40 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name+/-BaseCurrent+/- gzipBase gzipCurrent gzip
oss-experimental/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-experimental/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB

Generated by 🚫 dangerJS against 6a9c7bd

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

@hoxyq I may need your help... Two tests are failing and I don't understand what's wrong

  • profilingCache-test.js › should properly detect changed hooks
  • profilingCache-test.js › should detect context changes or lack of changes with conditional use()

They only fail when I call:

constprevHooks=inspectHooks(prevFiber);constnextHooks=inspectHooks(nextFiber);

I’ve narrowed it to this:

exportfunctioninspectHooks<Props>(renderFunction: (props: Props)=>React$Node,props: Props,currentDispatcher?: CurrentDispatcherRef,): HooksTree{if(currentDispatcher==null){currentDispatcher=ReactSharedInternals;}constpreviousDispatcher=currentDispatcher.H;currentDispatcher.H=DispatcherProxy;letreadHookLog;letancestorStackError;try{ancestorStackError=newError();renderFunction(props);}catch(error){handleRenderFunctionError(error);}finally{readHookLog=hookLog;hookLog=[];// $FlowFixMe[incompatible-use] found when upgrading FlowcurrentDispatcher.H=previousDispatcher;}constrootStack=ancestorStackError===undefined
? ([]: ParsedStackFrame[])
: ErrorStackParser.parse(ancestorStackError);returnbuildTree(rootStack,readHookLog);}

I suspect the issue comes from how inspectHooks temporarily swaps out the dispatcher. That dispatcher replacement might be overwriting or leaking some internal data, which then causes the change descriptions recorded by the Profiler to become incomplete.

@hoxyq
hoxyq self-requested a review November 19, 2025 19:24
@hoxyq

Copy link
Copy Markdown
Collaborator

Looking at the failed tests, it looks like the number of recorded changes doesn't match the number of performed updates. I don't see any early returns in the code, so I am not sure how this may actually happen.

I wonder if this could be related to the fact that we are actually calling render functions of components to build the hook tree.

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

FYI I'm looking into it again, maybe merging main would help 🤞

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

I can't run tests on the newest main due to this, is this a known problem or should I build differently? @hoxyq

@andreisilviudragnea

Copy link
Copy Markdown

Please have a look at #34427 too, maybe it's another solution to this issue @blazejkustra@hoxyq

@andreisilviudragnea

andreisilviudragnea commented Dec 25, 2025

Copy link
Copy Markdown

I think that merging #34547 without fixing the index counting error for all composite hooks just does not fix the initial problem at all, because having for example a useTransition hook before any other hook will invalidate the "Hook X changed" index for all hooks that follow useTransition

@blazejkustra

blazejkustra commented Dec 25, 2025

Copy link
Copy Markdown
ContributorAuthor

I think that merging #34547 without fixing the index counting error for all composite hooks just does not fix the initial problem at all, because having for example a useTransition hook before any other hook will invalidate the "Hook X changed" index for all hooks that follow useTransition

@andreisilviudragnea read through the discussion on my previous PR, especially this comment. The plan was to merge it and fix other hooks in follow up PRs. However, it turned out to be harder than expected and I haven't figure it out yet 🫠

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

It's a Christmas miracle!! 🎄

After some trial and error I finally realized that due to how inspectHooks() works the dispatcher was mocked for a moment, which overwrote the dispatch/setState variables the tests were storing 'globally'. I fixed it by saving references before they get overwritten.

Back to you @hoxyq 😄

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated
Comment on lines +2050 to +2054
// If the hook has subHooks, flatten them recursively
if (currentHook.subHooks && currentHook.subHooks.length > 0) {
flattened.push(...flattenHooksTree(currentHook.subHooks));
continue;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why this specific order is chosen?

This looks like subHooks wills have lower indexes in an array, when flattened. In the UI, the hook numbers (index + 1) usually reflect the tree structure. Basically indexOf(parent) < indexOf(parent.child) is always true.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flattenHooksTree function performs a depth-first traversal that only keeps leaf hooks (those without subHooks). This means custom hooks are unwrapped to primitive hooks.

For example, given this tree:

[
{ name: 'useCustomHook', subHooks: [
{ name: 'useState', value: 1 },
{ name: 'useEffect' }
]},
{ name: 'useState', value: 2 }
]

The flattened result is:

[
{ name: 'useState', value: 1 }, // index 0
{ name: 'useEffect' }, // index 1
{ name: 'useState', value: 2 } // index 2
]

This order matches React's hooks order, right? So comparing prevFlattened[i] with nextFlattened[i] identifies which primitive hooks changed between renders.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we were to change the traversal order the indexes wouldn't be correct I believe, wdyt @hoxyq?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying. I was confused a bit, but that is not an issue, since we only assign indexes to built-in hooks, which should be leaf nodes by definition.

In your example above, I believe this will be the result of flattening:

[
{ name: 'State', value: 1 }, // index 0
{ name: 'Effect' }, // index 1
{ name: 'CustomHook' }, { name: 'State', value: 2 } // index 2
]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it still makes sense to do flattening? We could probably just do dfs on both hook trees at the same time.

@blazejkustrablazejkustraJan 14, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think custom hook would not be there since it is omitted by the continue statement:

if(currentHook.subHooks&&currentHook.subHooks.length>0){flattened.push(...flattenHooksTree(currentHook.subHooks));continue;}

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably just do dfs on both hook trees at the same time.

Ahaa, so instead of building prevFlattened and nextFlattened I could do it in place? Good idea, let me try it 👀

@hoxyq

Copy link
Copy Markdown
Collaborator

These changes make sense, thank you for improving this. I left some clarifying questions.

Could you please also add some tests that will validate these changes? For example, the different (and correct) result of inspectHooks() calls when a complex component with useSyncExternalStore() or other hooks is used.

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

Back to you @hoxyq! I adjusted the code so it traverses in place and added a test as asked 🚀

I tried to run this new test on main and it failed miserably as expected :)

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated

@hoxyqhoxyq left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, please see my suggestion on making iterating over hook trees without optionality.

I can merge after it. We've accumulated some fixes that are not released yet, so I might release new patch / minor release later this month, but no concrete plans yet.

Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

I added your suggestions, seems that everything works well. Thank you!

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated
Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
@hoxyq
hoxyq merged commit 53daaf5 into react:mainJan 15, 2026
234 checks passed
@blazejkustra

blazejkustra commented Jan 15, 2026

Copy link
Copy Markdown
ContributorAuthor

I was wrong lol, good catch!

mdm317 added a commit to mdm317/react that referenced this pull request Apr 20, 2026
inspectHooksOfFiber is too expensive for this use case. On frequently updating
components, it can become slow enough to freeze the browser.
Revert hook change detection to the pre-react#35123 behavior. Then defer parsing
hook metadata, such as hook names, until after the commit completes, and add
caching so hook inspection runs at most once per fiber whenever possible.
For now, special handling is only implemented for SyncExternalStore,
Transition, ActionState, and FormState.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[DevTools Bug]: React Profiler reports higher hook numbers than shown in Components

4 participants

@blazejkustra@react-sizebot@hoxyq@andreisilviudragnea
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Improve the detection of changed hooks by blazejkustra · Pull Request #35123 · react/react · GitHub
Skip to content

Improve the detection of changed hooks - #35123

Merged
hoxyq merged 17 commits into
react:mainfrom
blazejkustra:fix/devtools-hook-indexes-mismatch
Jan 15, 2026
Merged

Improve the detection of changed hooks#35123
hoxyq merged 17 commits into
react:mainfrom
blazejkustra:fix/devtools-hook-indexes-mismatch

Conversation

@blazejkustra

@blazejkustrablazejkustra commented Nov 13, 2025

Copy link
Copy Markdown
Contributor

Summary

cc @hoxyq

Fixes#28584. Follow up to PR: #34547

This PR updates getChangedHooksIndices to account for the fact that useSyncExternalStore, useTransition, useActionState, useFormState internally mounts more than one hook while DevTools should treat it as a single user-facing hook.

Approach idea came from this comment 😄

Before:

QuickTime.movie.2.mov

After:

QuickTime.movie.mov

How did you test this change?

I used this component to reproduce this issue locally (I followed instructions in packages/react-devtools/CONTRIBUTING.md).

Details
import*asReactfrom'react';functionuseDeepNestedHook(){React.useState(0);// 1returnReact.useState(1);// 2}functionuseNestedHook(){constdeepState=useDeepNestedHook();React.useState(2);// 3React.useState(3);// 4returndeepState;}// Create a simple store for useSyncExternalStorefunctioncreateStore(initialValue){letvalue=initialValue;constlisteners=newSet();return{getSnapshot: ()=>value,subscribe: listener=>{listeners.add(listener);return()=>{listeners.delete(listener);};},update: newValue=>{value=newValue;listeners.forEach(listener=>listener());},};}constsyncExternalStore=createStore(0);exportdefaultfunctionInspectableElements(): React.Node{const[nestedState,setNestedState]=useNestedHook();// 5constsyncExternalValue=React.useSyncExternalStore(syncExternalStore.subscribe,syncExternalStore.getSnapshot,);// 6const[isPending,startTransition]=React.useTransition();// 7const[formState,formAction,formPending]=React.useActionState(async(prevState,formData)=>{return{count: (prevState?.count||0)+1};},{count: 0},);consthandleTransition=()=>{startTransition(()=>{setState(Math.random());});};// 8const[state,setState]=React.useState('test');return(<><divstyle={{padding: '20px',display: 'flex',flexDirection: 'column',gap: '10px',}}><divonClick={()=>setNestedState(Math.random())}style={{backgroundColor: 'red',padding: '10px',cursor: 'pointer'}}>State: {nestedState}</div><buttononClick={handleTransition}style={{padding: '10px'}}>TriggerTransition{isPending ? '(pending...)' : ''}</button><divstyle={{display: 'flex',gap: '10px',alignItems: 'center'}}><buttononClick={()=>syncExternalStore.update(syncExternalValue+1)}style={{padding: '10px'}}>TriggeruseSyncExternalStore</button><span>Value: {syncExternalValue}</span></div><formaction={formAction}style={{display: 'flex',gap: '10px',alignItems: 'center'}}><buttontype="submit"style={{padding: '10px'}}disabled={formPending}>TriggeruseFormState{formPending ? '(pending...)' : ''}</button><span>Count: {formState.count}</span></form><divonClick={()=>setState(Math.random())}style={{backgroundColor: 'red',padding: '10px',cursor: 'pointer'}}>State: {state}</div></div></>);}

@react-sizebot

react-sizebot commented Nov 13, 2025

Copy link
Copy Markdown

Comparing: 4a3d993...6a9c7bd

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name+/-BaseCurrent+/- gzipBase gzipCurrent gzip
oss-stable/react-dom/cjs/react-dom.production.js=6.84 kB6.84 kB+0.05%1.88 kB1.88 kB
oss-stable/react-dom/cjs/react-dom-client.production.js=608.03 kB608.03 kB=107.61 kB107.61 kB
oss-experimental/react-dom/cjs/react-dom.production.js=6.84 kB6.84 kB=1.88 kB1.88 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js=667.26 kB667.26 kB=117.51 kB117.51 kB
facebook-www/ReactDOM-prod.classic.js=693.38 kB693.38 kB=122.00 kB122.00 kB
facebook-www/ReactDOM-prod.modern.js=683.76 kB683.76 kB=120.40 kB120.40 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name+/-BaseCurrent+/- gzipBase gzipCurrent gzip
oss-experimental/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.production.js+0.25%29.70 kB29.78 kB+0.16%5.80 kB5.81 kB
oss-experimental/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.development.js+0.23%33.29 kB33.37 kB+0.14%5.92 kB5.93 kB

Generated by 🚫 dangerJS against 6a9c7bd

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

@hoxyq I may need your help... Two tests are failing and I don't understand what's wrong

  • profilingCache-test.js › should properly detect changed hooks
  • profilingCache-test.js › should detect context changes or lack of changes with conditional use()

They only fail when I call:

constprevHooks=inspectHooks(prevFiber);constnextHooks=inspectHooks(nextFiber);

I’ve narrowed it to this:

exportfunctioninspectHooks<Props>(renderFunction: (props: Props)=>React$Node,props: Props,currentDispatcher?: CurrentDispatcherRef,): HooksTree{if(currentDispatcher==null){currentDispatcher=ReactSharedInternals;}constpreviousDispatcher=currentDispatcher.H;currentDispatcher.H=DispatcherProxy;letreadHookLog;letancestorStackError;try{ancestorStackError=newError();renderFunction(props);}catch(error){handleRenderFunctionError(error);}finally{readHookLog=hookLog;hookLog=[];// $FlowFixMe[incompatible-use] found when upgrading FlowcurrentDispatcher.H=previousDispatcher;}constrootStack=ancestorStackError===undefined
? ([]: ParsedStackFrame[])
: ErrorStackParser.parse(ancestorStackError);returnbuildTree(rootStack,readHookLog);}

I suspect the issue comes from how inspectHooks temporarily swaps out the dispatcher. That dispatcher replacement might be overwriting or leaking some internal data, which then causes the change descriptions recorded by the Profiler to become incomplete.

@hoxyq
hoxyq self-requested a review November 19, 2025 19:24
@hoxyq

Copy link
Copy Markdown
Collaborator

Looking at the failed tests, it looks like the number of recorded changes doesn't match the number of performed updates. I don't see any early returns in the code, so I am not sure how this may actually happen.

I wonder if this could be related to the fact that we are actually calling render functions of components to build the hook tree.

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

FYI I'm looking into it again, maybe merging main would help 🤞

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

I can't run tests on the newest main due to this, is this a known problem or should I build differently? @hoxyq

@andreisilviudragnea

Copy link
Copy Markdown

Please have a look at #34427 too, maybe it's another solution to this issue @blazejkustra@hoxyq

@andreisilviudragnea

andreisilviudragnea commented Dec 25, 2025

Copy link
Copy Markdown

I think that merging #34547 without fixing the index counting error for all composite hooks just does not fix the initial problem at all, because having for example a useTransition hook before any other hook will invalidate the "Hook X changed" index for all hooks that follow useTransition

@blazejkustra

blazejkustra commented Dec 25, 2025

Copy link
Copy Markdown
ContributorAuthor

I think that merging #34547 without fixing the index counting error for all composite hooks just does not fix the initial problem at all, because having for example a useTransition hook before any other hook will invalidate the "Hook X changed" index for all hooks that follow useTransition

@andreisilviudragnea read through the discussion on my previous PR, especially this comment. The plan was to merge it and fix other hooks in follow up PRs. However, it turned out to be harder than expected and I haven't figure it out yet 🫠

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

It's a Christmas miracle!! 🎄

After some trial and error I finally realized that due to how inspectHooks() works the dispatcher was mocked for a moment, which overwrote the dispatch/setState variables the tests were storing 'globally'. I fixed it by saving references before they get overwritten.

Back to you @hoxyq 😄

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated
Comment on lines +2050 to +2054
// If the hook has subHooks, flatten them recursively
if (currentHook.subHooks && currentHook.subHooks.length > 0) {
flattened.push(...flattenHooksTree(currentHook.subHooks));
continue;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why this specific order is chosen?

This looks like subHooks wills have lower indexes in an array, when flattened. In the UI, the hook numbers (index + 1) usually reflect the tree structure. Basically indexOf(parent) < indexOf(parent.child) is always true.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flattenHooksTree function performs a depth-first traversal that only keeps leaf hooks (those without subHooks). This means custom hooks are unwrapped to primitive hooks.

For example, given this tree:

[
{ name: 'useCustomHook', subHooks: [
{ name: 'useState', value: 1 },
{ name: 'useEffect' }
]},
{ name: 'useState', value: 2 }
]

The flattened result is:

[
{ name: 'useState', value: 1 }, // index 0
{ name: 'useEffect' }, // index 1
{ name: 'useState', value: 2 } // index 2
]

This order matches React's hooks order, right? So comparing prevFlattened[i] with nextFlattened[i] identifies which primitive hooks changed between renders.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we were to change the traversal order the indexes wouldn't be correct I believe, wdyt @hoxyq?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying. I was confused a bit, but that is not an issue, since we only assign indexes to built-in hooks, which should be leaf nodes by definition.

In your example above, I believe this will be the result of flattening:

[
{ name: 'State', value: 1 }, // index 0
{ name: 'Effect' }, // index 1
{ name: 'CustomHook' }, { name: 'State', value: 2 } // index 2
]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it still makes sense to do flattening? We could probably just do dfs on both hook trees at the same time.

@blazejkustrablazejkustraJan 14, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think custom hook would not be there since it is omitted by the continue statement:

if(currentHook.subHooks&&currentHook.subHooks.length>0){flattened.push(...flattenHooksTree(currentHook.subHooks));continue;}

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably just do dfs on both hook trees at the same time.

Ahaa, so instead of building prevFlattened and nextFlattened I could do it in place? Good idea, let me try it 👀

@hoxyq

Copy link
Copy Markdown
Collaborator

These changes make sense, thank you for improving this. I left some clarifying questions.

Could you please also add some tests that will validate these changes? For example, the different (and correct) result of inspectHooks() calls when a complex component with useSyncExternalStore() or other hooks is used.

@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

Back to you @hoxyq! I adjusted the code so it traverses in place and added a test as asked 🚀

I tried to run this new test on main and it failed miserably as expected :)

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated

@hoxyqhoxyq left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, please see my suggestion on making iterating over hook trees without optionality.

I can merge after it. We've accumulated some fixes that are not released yet, so I might release new patch / minor release later this month, but no concrete plans yet.

Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
@blazejkustra

Copy link
Copy Markdown
ContributorAuthor

I added your suggestions, seems that everything works well. Thank you!

Comment threadpackages/react-devtools-shared/src/backend/fiber/renderer.js Outdated
Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
@hoxyq
hoxyq merged commit 53daaf5 into react:mainJan 15, 2026
234 checks passed
@blazejkustra

blazejkustra commented Jan 15, 2026

Copy link
Copy Markdown
ContributorAuthor

I was wrong lol, good catch!

mdm317 added a commit to mdm317/react that referenced this pull request Apr 20, 2026
inspectHooksOfFiber is too expensive for this use case. On frequently updating
components, it can become slow enough to freeze the browser.
Revert hook change detection to the pre-react#35123 behavior. Then defer parsing
hook metadata, such as hook names, until after the commit completes, and add
caching so hook inspection runs at most once per fiber whenever possible.
For now, special handling is only implemented for SyncExternalStore,
Transition, ActionState, and FormState.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[DevTools Bug]: React Profiler reports higher hook numbers than shown in Components

4 participants

@blazejkustra@react-sizebot@hoxyq@andreisilviudragnea