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++;