Skip to content

Improve the detection of changed hooks#191

Closed
everettbu wants to merge 16 commits into
mainfrom
fix/devtools-hook-indexes-mismatch
Closed

Improve the detection of changed hooks#191
everettbu wants to merge 16 commits into
mainfrom
fix/devtools-hook-indexes-mismatch

Conversation

@everettbu

@everettbu everettbu commented Dec 12, 2025

Copy link
Copy Markdown

Mirror of facebook/react#35123
Original author: blazejkustra


Summary

cc @hoxyq

Fixes react/react#28584. Follow up to PR: react/react#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 comment 😄

Before:

QuickTime.movie.2.mov

After:

QuickTime.movie.mov

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
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>
    </>
  );
}

@greptile-apps

greptile-apps Bot commented Dec 12, 2025

Copy link
Copy Markdown

Greptile Summary

This PR refactors hook change detection to better handle composite hooks like useSyncExternalStore, useTransition, and useActionState by switching from low-level fiber inspection to using inspectHooks() which returns a properly structured HooksTree.

Key changes:

  • Replaced fiber-based hook traversal with HooksTree-based comparison using recursive traverse() function
  • Fixed useSyncExternalStore to read memoized state instead of calling getSnapshot() which may have changed since render
  • Added comprehensive test coverage for composite hooks detection
  • Changed hooks field from null to empty array [] when no hooks changed

Critical issues found:

  • The traverse() function has a critical runtime bug when hook trees have different lengths between renders - it will crash when accessing properties on undefined and will miss changes when the next tree is longer than the previous tree

Confidence Score: 1/5

  • This PR has critical runtime bugs that will cause crashes in production
  • The traverse() function in renderer.js has three related critical bugs that will cause runtime errors when hook trees have different lengths. This can happen if React's internal hook implementation changes or if there are edge cases not covered by tests. The code will crash when trying to access properties on undefined hooks.
  • packages/react-devtools-shared/src/backend/fiber/renderer.js requires immediate attention - the traverse function needs guards for undefined values

Important Files Changed

Filename Overview
packages/react-devtools-shared/src/backend/fiber/renderer.js Critical bug in traverse function: crashes when hook trees have different lengths, and misses changes when nextTree is longer than prevTree
packages/react-debug-tools/src/ReactDebugHooks.js Fixed useSyncExternalStore to read from hook memoizedState instead of calling getSnapshot() which may have changed
packages/react-devtools-shared/src/tests/profilingCache-test.js Added test coverage for composite hooks detection and fixed test by saving function references before profiling

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 files reviewed, no comments

Edit Code Review Agent Settings | Greptile

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

if (isUseSyncExternalStoreHook(next)) {
next = next.next;
prev = prev.next;
if (prevHook === null || nextHook === null) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Check should handle undefined values. If nextFlattened.length < prevFlattened.length, accessing nextFlattened[index] returns undefined, not null, so this check won't catch it.

Suggested change
if (prevHook === null || nextHook === null) {
if (prevHook == null || nextHook == null) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/react-devtools-shared/src/backend/fiber/renderer.js
Line: 2078:2078

Comment:
**logic:** Check should handle `undefined` values. If `nextFlattened.length < prevFlattened.length`, accessing `nextFlattened[index]` returns `undefined`, not `null`, so this check won't catch it.

```suggestion
      if (prevHook == null || nextHook == null) {
```

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines 2078 to 2080
if (prevHook === null || nextHook === null) {
continue;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: check doesn't handle undefined - when nextFlattened.length < prevFlattened.length, nextFlattened[index] returns undefined, not null

Suggested change
if (prevHook === null || nextHook === null) {
continue;
}
if (prevHook == null || nextHook == null) {
continue;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/react-devtools-shared/src/backend/fiber/renderer.js
Line: 2078:2080

Comment:
**logic:** check doesn't handle `undefined` - when `nextFlattened.length < prevFlattened.length`, `nextFlattened[index]` returns `undefined`, not `null`

```suggestion
      if (prevHook == null || nextHook == null) {
        continue;
      }
```

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

indices.push(index);
}
function traverse(prevTree: HooksTree, nextTree: HooksTree): void {
for (let i = 0; i < prevTree.length; i++) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: loop should use the longer array's length to handle cases where trees differ in size

Suggested change
for (let i = 0; i < prevTree.length; i++) {
for (let i = 0; i < Math.max(prevTree.length, nextTree.length); i++) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/react-devtools-shared/src/backend/fiber/renderer.js
Line: 2058:2058

Comment:
**logic:** loop should use the longer array's length to handle cases where trees differ in size

```suggestion
      for (let i = 0; i < Math.max(prevTree.length, nextTree.length); i++) {
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +2059 to +2067
const prevHook = prevTree[i];
const nextHook = nextTree[i];

// 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?.subHooks?.length > 0 && nextHook?.subHooks?.length > 0) {
traverse(prevHook.subHooks, nextHook.subHooks);
continue;
}

next = next.next;
prev = prev.next;
index++;
if (didStatefulHookChange(prevHook, nextHook)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: add guard for when hooks are undefined due to array length mismatch

Suggested change
const prevHook = prevTree[i];
const nextHook = nextTree[i];
// 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?.subHooks?.length > 0 && nextHook?.subHooks?.length > 0) {
traverse(prevHook.subHooks, nextHook.subHooks);
continue;
}
next = next.next;
prev = prev.next;
index++;
if (didStatefulHookChange(prevHook, nextHook)) {
const prevHook = prevTree[i];
const nextHook = nextTree[i];
if (!prevHook || !nextHook) {
continue;
}
if (prevHook.subHooks.length > 0 && nextHook.subHooks.length > 0) {
traverse(prevHook.subHooks, nextHook.subHooks);
continue;
}
if (didStatefulHookChange(prevHook, nextHook)) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/react-devtools-shared/src/backend/fiber/renderer.js
Line: 2059:2067

Comment:
**logic:** add guard for when hooks are `undefined` due to array length mismatch

```suggestion
        const prevHook = prevTree[i];
        const nextHook = nextTree[i];

        if (!prevHook || !nextHook) {
          continue;
        }

        if (prevHook.subHooks.length > 0 && nextHook.subHooks.length > 0) {
          traverse(prevHook.subHooks, nextHook.subHooks);
          continue;
        }

        if (didStatefulHookChange(prevHook, nextHook)) {
```

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines +2057 to +2072
function traverse(prevTree: HooksTree, nextTree: HooksTree): void {
for (let i = 0; i < prevTree.length; i++) {
const prevHook = prevTree[i];
const nextHook = nextTree[i];

// 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?.subHooks?.length > 0 && nextHook?.subHooks?.length > 0) {
traverse(prevHook.subHooks, nextHook.subHooks);
continue;
}

next = next.next;
prev = prev.next;
index++;
if (didStatefulHookChange(prevHook, nextHook)) {
indices.push(index);
}

index++;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: runtime error when nextTree has fewer hooks than prevTree - accessing properties on undefined

when i >= nextTree.length, nextHook becomes undefined, and didStatefulHookChange(prevHook, nextHook) will throw trying to access next.isStateEditable or next.name

also misses changes when nextTree.length > prevTree.length since loop only iterates to prevTree.length

Suggested change
function traverse(prevTree: HooksTree, nextTree: HooksTree): void {
for (let i = 0; i < prevTree.length; i++) {
const prevHook = prevTree[i];
const nextHook = nextTree[i];
// 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?.subHooks?.length > 0 && nextHook?.subHooks?.length > 0) {
traverse(prevHook.subHooks, nextHook.subHooks);
continue;
}
next = next.next;
prev = prev.next;
index++;
if (didStatefulHookChange(prevHook, nextHook)) {
indices.push(index);
}
index++;
}
function traverse(prevTree: HooksTree, nextTree: HooksTree): void {
const maxLength = Math.max(prevTree.length, nextTree.length);
for (let i = 0; i < maxLength; i++) {
const prevHook = prevTree[i];
const nextHook = nextTree[i];
if (!prevHook || !nextHook) {
// Array length mismatch - treat as a change
indices.push(index);
index++;
continue;
}
if (prevHook.subHooks?.length > 0 && nextHook.subHooks?.length > 0) {
traverse(prevHook.subHooks, nextHook.subHooks);
continue;
}
if (didStatefulHookChange(prevHook, nextHook)) {
indices.push(index);
}
index++;
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/react-devtools-shared/src/backend/fiber/renderer.js
Line: 2057:2072

Comment:
**logic:** runtime error when `nextTree` has fewer hooks than `prevTree` - accessing properties on `undefined`

when `i >= nextTree.length`, `nextHook` becomes `undefined`, and `didStatefulHookChange(prevHook, nextHook)` will throw trying to access `next.isStateEditable` or `next.name`

also misses changes when `nextTree.length > prevTree.length` since loop only iterates to `prevTree.length`

```suggestion
    function traverse(prevTree: HooksTree, nextTree: HooksTree): void {
      const maxLength = Math.max(prevTree.length, nextTree.length);
      for (let i = 0; i < maxLength; i++) {
        const prevHook = prevTree[i];
        const nextHook = nextTree[i];

        if (!prevHook || !nextHook) {
          // Array length mismatch - treat as a change
          indices.push(index);
          index++;
          continue;
        }

        if (prevHook.subHooks?.length > 0 && nextHook.subHooks?.length > 0) {
          traverse(prevHook.subHooks, nextHook.subHooks);
          continue;
        }

        if (didStatefulHookChange(prevHook, nextHook)) {
          indices.push(index);
        }

        index++;
      }
    }
```

How can I resolve this? If you propose a fix, please make it concise.

Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +2058 to +2060
for (let i = 0; i < prevTree.length; i++) {
const prevHook = prevTree[i];
const nextHook = nextTree[i];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: loop must use Math.max(prevTree.length, nextTree.length) to handle trees of different sizes

when i >= nextTree.length, nextHook is undefined, causing runtime error in didStatefulHookChange when accessing next.isStateEditable or next.name

also misses changes when nextTree.length > prevTree.length since loop only iterates to prevTree.length

Suggested change
for (let i = 0; i < prevTree.length; i++) {
const prevHook = prevTree[i];
const nextHook = nextTree[i];
for (let i = 0; i < Math.max(prevTree.length, nextTree.length); i++) {
const prevHook = prevTree[i];
const nextHook = nextTree[i];

Are hook trees expected to always have the same length between renders, or can components conditionally call different numbers of hooks?

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/react-devtools-shared/src/backend/fiber/renderer.js
Line: 2058:2060

Comment:
**logic:** loop must use `Math.max(prevTree.length, nextTree.length)` to handle trees of different sizes

when `i >= nextTree.length`, `nextHook` is `undefined`, causing runtime error in `didStatefulHookChange` when accessing `next.isStateEditable` or `next.name`

also misses changes when `nextTree.length > prevTree.length` since loop only iterates to `prevTree.length`

```suggestion
      for (let i = 0; i < Math.max(prevTree.length, nextTree.length); i++) {
        const prevHook = prevTree[i];
        const nextHook = nextTree[i];
```

 Are hook trees expected to always have the same length between renders, or can components conditionally call different numbers of hooks?

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +2062 to +2065
if (prevHook.subHooks?.length > 0 && nextHook.subHooks?.length > 0) {
traverse(prevHook.subHooks, nextHook.subHooks);
continue;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: doesn't handle when prevHook or nextHook is undefined due to mismatched tree lengths

accessing .subHooks on undefined will throw

Suggested change
if (prevHook.subHooks?.length > 0 && nextHook.subHooks?.length > 0) {
traverse(prevHook.subHooks, nextHook.subHooks);
continue;
}
if (prevHook?.subHooks?.length > 0 && nextHook?.subHooks?.length > 0) {
traverse(prevHook.subHooks, nextHook.subHooks);
continue;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/react-devtools-shared/src/backend/fiber/renderer.js
Line: 2062:2065

Comment:
**logic:** doesn't handle when `prevHook` or `nextHook` is `undefined` due to mismatched tree lengths

accessing `.subHooks` on `undefined` will throw

```suggestion
        if (prevHook?.subHooks?.length > 0 && nextHook?.subHooks?.length > 0) {
          traverse(prevHook.subHooks, nextHook.subHooks);
          continue;
        }
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +2067 to +2069
if (didStatefulHookChange(prevHook, nextHook)) {
indices.push(index);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: didStatefulHookChange will crash if either hook is undefined

needs guard to handle mismatched tree lengths

Suggested change
if (didStatefulHookChange(prevHook, nextHook)) {
indices.push(index);
}
if (prevHook && nextHook && didStatefulHookChange(prevHook, nextHook)) {
indices.push(index);
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/react-devtools-shared/src/backend/fiber/renderer.js
Line: 2067:2069

Comment:
**logic:** `didStatefulHookChange` will crash if either hook is `undefined`

needs guard to handle mismatched tree lengths

```suggestion
        if (prevHook && nextHook && didStatefulHookChange(prevHook, nextHook)) {
          indices.push(index);
        }
```

How can I resolve this? If you propose a fix, please make it concise.

@everettbu

Copy link
Copy Markdown
Author

Upstream PR was closed or merged. Code is synced via branch mirror.

@everettbu everettbu closed this Jan 15, 2026
@everettbu everettbu deleted the fix/devtools-hook-indexes-mismatch branch January 15, 2026 11:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[DevTools Bug]: React Profiler reports higher hook numbers than shown in Components

2 participants