From 39c6545cef85b5251e519080fd315bff728d87de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82a=C5=BCej=20Kustra?= <46095609+blazejkustra@users.noreply.github.com> Date: Tue, 21 Oct 2025 14:59:20 +0200 Subject: [PATCH] Fix indices of hooks in devtools when using useSyncExternalStore (#34547) ## Summary This PR updates getChangedHooksIndices to account for the fact that useSyncExternalStore internally mounts two hooks, while DevTools should treat it as a single user-facing hook. It introduces a helper isUseSyncExternalStoreHook to detect this case and adjust iteration so the extra internal hook is skipped when counting changes. Before: https://github.com/user-attachments/assets/0db72a4e-21f7-44c7-ba02-669a272631e5 After: https://github.com/user-attachments/assets/4da71392-0396-408d-86a7-6fbc82d8c4f5 ## How did you test this change? I used this component to reproduce this issue locally (I followed instructions in `packages/react-devtools/CONTRIBUTING.md`). ```ts function Test() { // 1 React.useSyncExternalStore( () => {}, () => {}, () => {}, ); // 2 const [state, setState] = useState('test'); return ( <>
setState(Math.random())} style={{backgroundColor: 'red'}}> {state}
); } ``` --- .../src/backend/fiber/renderer.js | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index fe8722da56..9de9037690 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -1913,6 +1913,20 @@ 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 isHookThatCanScheduleUpdate(hookObject: any) { const queue = hookObject.queue; if (!queue) { @@ -1929,12 +1943,7 @@ export function attach( return true; } - // Detect useSyncExternalStore() - return ( - boundHasOwnProperty('value') && - boundHasOwnProperty('getSnapshot') && - typeof queue.getSnapshot === 'function' - ); + return isUseSyncExternalStoreHook(hookObject); } function didStatefulHookChange(prev: any, next: any): boolean { @@ -1955,10 +1964,18 @@ export function attach( const indices = []; let index = 0; + while (next !== null) { if (didStatefulHookChange(prev, next)) { indices.push(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; + } + next = next.next; prev = prev.next; index++;