Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
249 changes: 246 additions & 3 deletions packages/react-devtools-shared/src/__tests__/profilingCache-test.js
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 @@ -521,6 +527,238 @@ describe('ProfilingCache', () => {
}
});

// @reactVersion >= 19.0
it('should detect what hooks changed in a render with custom and composite hooks', () => {
let snapshot = 0;
let syncExternalStoreCallback;

function subscribe(callback) {
syncExternalStoreCallback = callback;
return () => {};
}

function getSnapshot() {
return snapshot;
}

// Custom hook wrapping multiple primitive hooks
function useCustomHook() {
const [value, setValue] = React.useState('custom');
React.useEffect(() => {}, [value]);
return [value, setValue];
}

let setState = null;
let startTransition = null;
let actionStateDispatch = null;
let setCustomValue = null;
let setFinalState = null;

const Component = () => {
// Hook 0: useState
const [state, _setState] = React.useState('initial');
setState = _setState;

// Hook 1: useSyncExternalStore (composite hook - internally uses multiple hooks)
const storeValue = React.useSyncExternalStore(
subscribe,
getSnapshot,
getSnapshot,
);

// Hook 2: useTransition (composite hook - internally uses multiple hooks)
const [isPending, _startTransition] = React.useTransition();
startTransition = _startTransition;

// Hook 3: useActionState (composite hook - internally uses multiple hooks)
const [actionState, _actionStateDispatch] = React.useActionState(
(_prev, action) => action,
'action-initial',
);
actionStateDispatch = _actionStateDispatch;

// Hook 4: useState inside custom hook (flattened)
// Hook 5: useEffect inside custom hook (not stateful, won't show in changes)
const [customValue, _setCustomValue] = useCustomHook();
setCustomValue = _setCustomValue;

// Hook 6: direct useState at the end
const [finalState, _setFinalState] = React.useState('final');
setFinalState = _setFinalState;

return `${state}-${storeValue}-${isPending}-${actionState}-${customValue}-${finalState}`;
};

utils.act(() => store.profilerStore.startProfiling());
utils.act(() => render(<Component />));

// Save references before inspectHooks() overwrites them
const realSetState = setState;
const realStartTransition = startTransition;
const realActionStateDispatch = actionStateDispatch;
const realSetCustomValue = setCustomValue;
const realSetFinalState = setFinalState;

// 2nd render: change useState (hook 0)
utils.act(() => realSetState('changed'));

// 3rd render: change useSyncExternalStore (hook 1)
utils.act(() => {
snapshot = 1;
syncExternalStoreCallback();
});

// 4th render: trigger useTransition (hook 2)
// Note: useTransition triggers two renders - one when isPending becomes true,
// and another when isPending becomes false after the transition completes
utils.act(() => {
realStartTransition(() => {});
});

// 6th render: change useActionState (hook 3)
utils.act(() => realActionStateDispatch('action-changed'));

// 7th render: change custom hook's useState (hook 4)
utils.act(() => realSetCustomValue('custom-changed'));

// 8th render: change final useState (hook 6)
utils.act(() => realSetFinalState('final-changed'));

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

const rootID = store.roots[0];

const changeDescriptions = store.profilerStore
.getDataForRoot(rootID)
.commitData.map(commitData => commitData.changeDescriptions);
expect(changeDescriptions).toHaveLength(8);

// 1st render: Initial mount
expect(changeDescriptions[0]).toMatchInlineSnapshot(`
Map {
2 => {
"context": null,
"didHooksChange": false,
"isFirstMount": true,
"props": null,
"state": null,
},
}
`);

// 2nd render: Changed hook 0 (useState)
expect(changeDescriptions[1]).toMatchInlineSnapshot(`
Map {
2 => {
"context": false,
"didHooksChange": true,
"hooks": [
0,
],
"isFirstMount": false,
"props": [],
"state": null,
},
}
`);

// 3rd render: Changed hook 1 (useSyncExternalStore)
expect(changeDescriptions[2]).toMatchInlineSnapshot(`
Map {
2 => {
"context": false,
"didHooksChange": true,
"hooks": [
1,
],
"isFirstMount": false,
"props": [],
"state": null,
},
}
`);

// 4th render: Changed hook 2 (useTransition - isPending becomes true)
expect(changeDescriptions[3]).toMatchInlineSnapshot(`
Map {
2 => {
"context": false,
"didHooksChange": true,
"hooks": [
2,
],
"isFirstMount": false,
"props": [],
"state": null,
},
}
`);

// 5th render: Changed hook 2 (useTransition - isPending becomes false)
expect(changeDescriptions[4]).toMatchInlineSnapshot(`
Map {
2 => {
"context": false,
"didHooksChange": true,
"hooks": [
2,
],
"isFirstMount": false,
"props": [],
"state": null,
},
}
`);

// 6th render: Changed hook 3 (useActionState)
expect(changeDescriptions[5]).toMatchInlineSnapshot(`
Map {
2 => {
"context": false,
"didHooksChange": true,
"hooks": [
3,
],
"isFirstMount": false,
"props": [],
"state": null,
},
}
`);

// 7th render: Changed hook 4 (useState inside useCustomHook)
expect(changeDescriptions[6]).toMatchInlineSnapshot(`
Map {
2 => {
"context": false,
"didHooksChange": true,
"hooks": [
4,
],
"isFirstMount": false,
"props": [],
"state": null,
},
}
`);

// 8th render: Changed hook 6 (final useState)
expect(changeDescriptions[7]).toMatchInlineSnapshot(`
Map {
2 => {
"context": false,
"didHooksChange": true,
"hooks": [
6,
],
"isFirstMount": false,
"props": [],
"state": null,
},
}
`);
});

// @reactVersion >= 19.0
it('should detect context changes or lack of changes with conditional use()', () => {
const ContextA = React.createContext(0);
Expand Down Expand Up @@ -553,6 +791,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 +810,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
Loading
Loading