Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4e09f54
Improve the detection of changed hooks
blazejkustra Nov 13, 2025
7120a95
Add 'FormState' to the list of editable state names in hook detection
blazejkustra Nov 13, 2025
b77efa4
Fix lint
blazejkustra Nov 13, 2025
7371cfc
Fix tests 4/10
blazejkustra Nov 13, 2025
de00387
Use dispatcherHookName instead of name
blazejkustra Nov 13, 2025
ddf0277
Update hook detection to use 'name' property instead of 'dispatcherHo…
blazejkustra Nov 13, 2025
f565055
Refactor hook inspection to simplify detection logic
blazejkustra Nov 13, 2025
c0ec480
Merge branch 'main' of github.com:facebook/react into fix/devtools-ho…
blazejkustra Dec 12, 2025
2cae0a1
Merge branch 'main' of github.com:facebook/react into fix/devtools-ho…
blazejkustra Dec 25, 2025
7a68cae
Fix profiling cache tests to preserve real dispatch and setState refe…
blazejkustra Dec 25, 2025
ac421e0
Merge branch 'main' of github.com:facebook/react into fix/devtools-ho…
blazejkustra Jan 14, 2026
82f5ede
Removed flattenHooksTree function and replaced it with a recursive tr…
blazejkustra Jan 14, 2026
78545f2
Fix hook traversal logic
blazejkustra Jan 14, 2026
3df7554
Remove potentially unreachable code
blazejkustra Jan 14, 2026
4b7295b
Add test for detecting changes in custom and composite hooks in Profi…
blazejkustra Jan 14, 2026
2ee5680
Remove unnecessary optional chaining
blazejkustra Jan 14, 2026
6a9c7bd
Update optional chaining for subhooks
blazejkustra Jan 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/react-debug-tools/src/ReactDebugHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -467,9 +467,11 @@ function useSyncExternalStore<T>(
// useSyncExternalStore() composes multiple hooks internally.
// Advance the current hook index the same number of times
// so that subsequent hooks have the right memoized state.
nextHook(); // SyncExternalStore
const hook = nextHook(); // SyncExternalStore
nextHook(); // Effect
const value = getSnapshot();
// Read from hook.memoizedState to get the value that was used during render,
// not the current value from getSnapshot() which may have changed.
const value = hook !== null ? hook.memoizedState : getSnapshot();
hookLog.push({
displayName: null,
primitive: 'SyncExternalStore',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ describe('Profiler change descriptions', () => {
{
"context": true,
"didHooksChange": false,
"hooks": null,
"hooks": [],
"isFirstMount": false,
"props": [],
"state": null,
Expand All @@ -110,7 +110,7 @@ describe('Profiler change descriptions', () => {
{
"context": true,
"didHooksChange": false,
"hooks": null,
"hooks": [],
"isFirstMount": false,
"props": [],
"state": null,
Expand All @@ -125,7 +125,7 @@ describe('Profiler change descriptions', () => {
{
"context": false,
"didHooksChange": false,
"hooks": null,
"hooks": [],
"isFirstMount": false,
"props": [],
"state": null,
Expand All @@ -140,7 +140,7 @@ describe('Profiler change descriptions', () => {
{
"context": true,
"didHooksChange": false,
"hooks": null,
"hooks": [],
"isFirstMount": false,
"props": [],
"state": null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,12 @@ describe('ProfilingCache', () => {
),
);

// Save references to the real dispatch/setState functions.
// inspectHooks() re-runs the component with a mock dispatcher,
// which would overwrite these variables with mock functions that do nothing.
const realDispatch = dispatch;
const realSetState = setState;

// Second render has no changed hooks, only changed props.
utils.act(() =>
render(
Expand All @@ -388,10 +394,10 @@ describe('ProfilingCache', () => {
);

// Third render has a changed reducer hook.
utils.act(() => dispatch({type: 'invert'}));
utils.act(() => realDispatch({type: 'invert'}));

// Fourth render has a changed state hook.
utils.act(() => setState('def'));
utils.act(() => realSetState('def'));

// Fifth render has a changed context value, but no changed hook.
utils.act(() =>
Expand Down Expand Up @@ -553,6 +559,11 @@ describe('ProfilingCache', () => {
),
);

// Save reference to the real setState function before profiling starts.
// inspectHooks() re-runs the component with a mock dispatcher,
// which would overwrite setState with a mock function that does nothing.
const realSetState = setState;

utils.act(() => store.profilerStore.startProfiling());

// First render changes Context.
Expand All @@ -567,7 +578,7 @@ describe('ProfilingCache', () => {
);

// Second render has no changed Context, only changed state.
utils.act(() => setState('def'));
utils.act(() => realSetState('def'));

utils.act(() => store.profilerStore.stopProfiling());

Expand Down
102 changes: 45 additions & 57 deletions packages/react-devtools-shared/src/backend/fiber/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type {
Wakeable,
} from 'shared/ReactTypes';

import type {HooksTree} from 'react-debug-tools/src/ReactDebugHooks';
import type {HooksNode, HooksTree} from 'react-debug-tools/src/ReactDebugHooks';

import {
ComponentFilterDisplayName,
Expand Down Expand Up @@ -127,7 +127,6 @@ import {enableStyleXFeatures} from 'react-devtools-feature-flags';
import {componentInfoToComponentLogsMap} from '../shared/DevToolsServerComponentLogs';

import is from 'shared/objectIs';
import hasOwnProperty from 'shared/hasOwnProperty';

import {getIODescription} from 'shared/ReactIODescription';

Expand Down Expand Up @@ -1976,10 +1975,9 @@ export function attach(
state: null,
};
} else {
const indices = getChangedHooksIndices(
prevFiber.memoizedState,
nextFiber.memoizedState,
);
const prevHooks = inspectHooks(prevFiber);
const nextHooks = inspectHooks(nextFiber);
const indices = getChangedHooksIndices(prevHooks, nextHooks);
const data: ChangeDescription = {
context: getContextChanged(prevFiber, nextFiber),
didHooksChange: indices !== null && indices.length > 0,
Expand Down Expand Up @@ -2028,72 +2026,62 @@ export function attach(
return false;
}

function isUseSyncExternalStoreHook(hookObject: any): boolean {
const queue = hookObject.queue;
if (!queue) {
return false;
}

const boundHasOwnProperty = hasOwnProperty.bind(queue);
return (
boundHasOwnProperty('value') &&
boundHasOwnProperty('getSnapshot') &&
typeof queue.getSnapshot === 'function'
);
}
function didStatefulHookChange(prev: HooksNode, next: HooksNode): boolean {
// Detect the shape of useState() / useReducer() / useTransition() / useSyncExternalStore() / useActionState()
const isStatefulHook =
prev.isStateEditable === true ||
prev.name === 'SyncExternalStore' ||
prev.name === 'Transition' ||
prev.name === 'ActionState' ||
prev.name === 'FormState';

function isHookThatCanScheduleUpdate(hookObject: any) {
const queue = hookObject.queue;
if (!queue) {
return false;
}

const boundHasOwnProperty = hasOwnProperty.bind(queue);

// Detect the shape of useState() / useReducer() / useTransition()
// using the attributes that are unique to these hooks
// but also stable (e.g. not tied to current Lanes implementation)
// We don't check for dispatch property, because useTransition doesn't have it
if (boundHasOwnProperty('pending')) {
return true;
// Compare the values to see if they changed
if (isStatefulHook) {
return prev.value !== next.value;
}

return isUseSyncExternalStoreHook(hookObject);
return false;
}

function didStatefulHookChange(prev: any, next: any): boolean {
const prevMemoizedState = prev.memoizedState;
const nextMemoizedState = next.memoizedState;

if (isHookThatCanScheduleUpdate(prev)) {
return prevMemoizedState !== nextMemoizedState;
function flattenHooksTree(hooksTree: HooksTree): HooksTree {
const flattened: HooksTree = [];
for (let i = 0; i < hooksTree.length; i++) {
const currentHook = hooksTree[i];
// 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
Contributor

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
Contributor Author

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
Contributor Author

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
Contributor

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
Contributor

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.

@blazejkustra blazejkustra Jan 14, 2026

Copy link
Copy Markdown
Contributor Author

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
Contributor Author

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 👀

// If the hook doesn't have subHooks, add it to the flattened list
flattened.push(currentHook);
}

return false;
return flattened;
}

function getChangedHooksIndices(prev: any, next: any): null | Array<number> {
if (prev == null || next == null) {
function getChangedHooksIndices(
prevHooks: HooksTree | null,
nextHooks: HooksTree | null,
): null | Array<number> {
if (prevHooks == null || nextHooks == null) {
return null;
}

const indices = [];
let index = 0;
const prevFlattened = flattenHooksTree(prevHooks);
const nextFlattened = flattenHooksTree(nextHooks);

while (next !== null) {
if (didStatefulHookChange(prev, next)) {
indices.push(index);
}
const indices: Array<number> = [];

for (let index = 0; index < prevFlattened.length; index++) {
const prevHook = prevFlattened[index];
const nextHook = nextFlattened[index];

// useSyncExternalStore creates 2 internal hooks, but we only count it as 1 user-facing hook
if (isUseSyncExternalStoreHook(next)) {
next = next.next;
prev = prev.next;
if (prevHook === null || nextHook === null) {
continue;
}
Comment thread
blazejkustra marked this conversation as resolved.
Outdated

next = next.next;
prev = prev.next;
index++;
if (didStatefulHookChange(prevHook, nextHook)) {
indices.push(index);
}
}

return indices;
Expand Down
Loading