From 4e09f54c7012496cedec822bff0b6f0cb49eb2d2 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Thu, 13 Nov 2025 01:13:01 +0100 Subject: [PATCH 1/7] Improve the detection of changed hooks --- .../react-debug-tools/src/ReactDebugHooks.js | 6 +- .../src/backend/fiber/renderer.js | 101 ++++++++---------- 2 files changed, 51 insertions(+), 56 deletions(-) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index db9495a97dd4d..b4eb6c2b59652 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -467,9 +467,11 @@ function useSyncExternalStore( // 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', diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index fa802e53a6f26..6ed95086e1793 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -1975,10 +1975,15 @@ export function attach( state: null, }; } else { - const indices = getChangedHooksIndices( - prevFiber.memoizedState, - nextFiber.memoizedState, + const prevHooks = inspectHooksOfFiber( + prevFiber, + getDispatcherRef(renderer), + ); + const nextHooks = inspectHooksOfFiber( + nextFiber, + getDispatcherRef(renderer), ); + const indices = getChangedHooksIndices(prevHooks, nextHooks); const data: ChangeDescription = { context: getContextChanged(prevFiber, nextFiber), didHooksChange: indices !== null && indices.length > 0, @@ -2027,72 +2032,60 @@ 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) { - return false; - } - - const boundHasOwnProperty = hasOwnProperty.bind(queue); + 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'; - // 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 (const hook of hooksTree) { + // If the hook has subHooks, flatten them recursively + if (hook.subHooks && hook.subHooks.length > 0) { + flattened.push(...flattenHooksTree(hook.subHooks)); + continue; + } + // If the hook doesn't have subHooks, add it to the flattened list + flattened.push(hook); } - - return false; + return flattened; } - function getChangedHooksIndices(prev: any, next: any): null | Array { - if (prev == null || next == null) { + function getChangedHooksIndices( + prevHooks: HooksTree | null, + nextHooks: HooksTree | null, + ): null | Array { + 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 = []; + + 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; } - next = next.next; - prev = prev.next; - index++; + if (didStatefulHookChange(prevHook, nextHook)) { + indices.push(index); + } } return indices; From 7120a9591d324743aeb125d6cdf9a8ec97d6bac7 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Thu, 13 Nov 2025 01:20:33 +0100 Subject: [PATCH 2/7] Add 'FormState' to the list of editable state names in hook detection --- packages/react-devtools-shared/src/backend/fiber/renderer.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 6ed95086e1793..edfb458f461b7 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2038,7 +2038,8 @@ export function attach( prev.isStateEditable === true || prev.name === 'SyncExternalStore' || prev.name === 'Transition' || - prev.name === 'ActionState'; + prev.name === 'ActionState' || + prev.name === 'FormState'; // Compare the values to see if they changed if (isStatefulHook) { From b77efa40a07bc7dc285f9d2c01a1c9e6e4505d6e Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Thu, 13 Nov 2025 01:24:23 +0100 Subject: [PATCH 3/7] Fix lint --- .../src/backend/fiber/renderer.js | 12 ++++++------ 1 file changed, 6 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 edfb458f461b7..90c789c611722 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -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, @@ -126,7 +126,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'; @@ -2051,14 +2050,15 @@ export function attach( function flattenHooksTree(hooksTree: HooksTree): HooksTree { const flattened: HooksTree = []; - for (const hook of hooksTree) { + for (let i = 0; i < hooksTree.length; i++) { + const currentHook = hooksTree[i]; // If the hook has subHooks, flatten them recursively - if (hook.subHooks && hook.subHooks.length > 0) { - flattened.push(...flattenHooksTree(hook.subHooks)); + if (currentHook.subHooks && currentHook.subHooks.length > 0) { + flattened.push(...flattenHooksTree(currentHook.subHooks)); continue; } // If the hook doesn't have subHooks, add it to the flattened list - flattened.push(hook); + flattened.push(currentHook); } return flattened; } From 7371cfce200c02c394809ef8338068e5aea7b2dc Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Thu, 13 Nov 2025 21:05:51 +0100 Subject: [PATCH 4/7] Fix tests 4/10 --- .../src/__tests__/profilerChangeDescriptions-test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-devtools-shared/src/__tests__/profilerChangeDescriptions-test.js b/packages/react-devtools-shared/src/__tests__/profilerChangeDescriptions-test.js index 87d8132e50e63..4710f5cd1b271 100644 --- a/packages/react-devtools-shared/src/__tests__/profilerChangeDescriptions-test.js +++ b/packages/react-devtools-shared/src/__tests__/profilerChangeDescriptions-test.js @@ -90,7 +90,7 @@ describe('Profiler change descriptions', () => { { "context": true, "didHooksChange": false, - "hooks": null, + "hooks": [], "isFirstMount": false, "props": [], "state": null, @@ -110,7 +110,7 @@ describe('Profiler change descriptions', () => { { "context": true, "didHooksChange": false, - "hooks": null, + "hooks": [], "isFirstMount": false, "props": [], "state": null, @@ -125,7 +125,7 @@ describe('Profiler change descriptions', () => { { "context": false, "didHooksChange": false, - "hooks": null, + "hooks": [], "isFirstMount": false, "props": [], "state": null, @@ -140,7 +140,7 @@ describe('Profiler change descriptions', () => { { "context": true, "didHooksChange": false, - "hooks": null, + "hooks": [], "isFirstMount": false, "props": [], "state": null, From de00387bd273cc6dbe0fa822310336942e42e997 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Thu, 13 Nov 2025 21:06:05 +0100 Subject: [PATCH 5/7] Use dispatcherHookName instead of name --- .../react-devtools-shared/src/backend/fiber/renderer.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 90c789c611722..d2fb702ef505d 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2035,10 +2035,10 @@ export function attach( // 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'; + prev.dispatcherHookName === 'SyncExternalStore' || + prev.dispatcherHookName === 'Transition' || + prev.dispatcherHookName === 'ActionState' || + prev.dispatcherHookName === 'FormState'; // Compare the values to see if they changed if (isStatefulHook) { From ddf02771aae68d32863b081b79c662e704aaf3cd Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Thu, 13 Nov 2025 21:40:09 +0100 Subject: [PATCH 6/7] Update hook detection to use 'name' property instead of 'dispatcherHookName' --- .../react-devtools-shared/src/backend/fiber/renderer.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index d2fb702ef505d..90c789c611722 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2035,10 +2035,10 @@ export function attach( // Detect the shape of useState() / useReducer() / useTransition() / useSyncExternalStore() / useActionState() const isStatefulHook = prev.isStateEditable === true || - prev.dispatcherHookName === 'SyncExternalStore' || - prev.dispatcherHookName === 'Transition' || - prev.dispatcherHookName === 'ActionState' || - prev.dispatcherHookName === 'FormState'; + prev.name === 'SyncExternalStore' || + prev.name === 'Transition' || + prev.name === 'ActionState' || + prev.name === 'FormState'; // Compare the values to see if they changed if (isStatefulHook) { From f56505575389a2734c14198d6b7a0714db41742c Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Thu, 13 Nov 2025 22:28:53 +0100 Subject: [PATCH 7/7] Refactor hook inspection to simplify detection logic --- .../src/backend/fiber/renderer.js | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 90c789c611722..25757c6b1f9f1 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -1974,14 +1974,8 @@ export function attach( state: null, }; } else { - const prevHooks = inspectHooksOfFiber( - prevFiber, - getDispatcherRef(renderer), - ); - const nextHooks = inspectHooksOfFiber( - nextFiber, - getDispatcherRef(renderer), - ); + const prevHooks = inspectHooks(prevFiber); + const nextHooks = inspectHooks(nextFiber); const indices = getChangedHooksIndices(prevHooks, nextHooks); const data: ChangeDescription = { context: getContextChanged(prevFiber, nextFiber),