Improve the detection of changed hooks (#35123)
## Summary
cc @hoxyq
Fixes https://github.com/facebook/react/issues/28584. Follow up to PR:
https://github.com/facebook/react/pull/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](https://github.com/facebook/react/pull/34547#issuecomment-3504113776)
comment 😄
Before:
https://github.com/user-attachments/assets/6bd5ce80-8b52-4bb8-8bb1-5e91b1e65043
After:
https://github.com/user-attachments/assets/47f56898-ab34-46b6-be7a-a54024dcefee
## 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><summary>Details</summary>
```ts
import * as React from 'react';
function useDeepNestedHook() {
React.useState(0); // 1
return React.useState(1); // 2
}
function useNestedHook() {
const deepState = useDeepNestedHook();
React.useState(2); // 3
React.useState(3); // 4
return deepState;
}
// Create a simple store for useSyncExternalStore
function createStore(initialValue) {
let value = initialValue;
const listeners = new Set();
return {
getSnapshot: () => value,
subscribe: listener => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
update: newValue => {
value = newValue;
listeners.forEach(listener => listener());
},
};
}
const syncExternalStore = createStore(0);
export default function InspectableElements(): React.Node {
const [nestedState, setNestedState] = useNestedHook();
// 5
const syncExternalValue = React.useSyncExternalStore(
syncExternalStore.subscribe,
syncExternalStore.getSnapshot,
);
// 6
const [isPending, startTransition] = React.useTransition();
// 7
const [formState, formAction, formPending] = React.useActionState(
async (prevState, formData) => {
return {count: (prevState?.count || 0) + 1};
},
{count: 0},
);
const handleTransition = () => {
startTransition(() => {
setState(Math.random());
});
};
// 8
const [state, setState] = React.useState('test');
return (
<>
<div
style={{
padding: '20px',
display: 'flex',
flexDirection: 'column',
gap: '10px',
}}>
<div
onClick={() => setNestedState(Math.random())}
style={{backgroundColor: 'red', padding: '10px', cursor: 'pointer'}}>
State: {nestedState}
</div>
<button onClick={handleTransition} style={{padding: '10px'}}>
Trigger Transition {isPending ? '(pending...)' : ''}
</button>
<div style={{display: 'flex', gap: '10px', alignItems: 'center'}}>
<button
onClick={() => syncExternalStore.update(syncExternalValue + 1)}
style={{padding: '10px'}}>
Trigger useSyncExternalStore
</button>
<span>Value: {syncExternalValue}</span>
</div>
<form
action={formAction}
style={{display: 'flex', gap: '10px', alignItems: 'center'}}>
<button
type="submit"
style={{padding: '10px'}}
disabled={formPending}>
Trigger useFormState {formPending ? '(pending...)' : ''}
</button>
<span>Count: {formState.count}</span>
</form>
<div
onClick={() => setState(Math.random())}
style={{backgroundColor: 'red', padding: '10px', cursor: 'pointer'}}>
State: {state}
</div>
</div>
</>
);
}
```
</details>
---------
Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>