[DevTools] feat: show changed hooks names in the Profiler tab (#31398)

<!--
  Thanks for submitting a pull request!
We appreciate you spending the time to work on these changes. Please
provide enough information so that others can review your pull request.
The three fields below are mandatory.

Before submitting a pull request, please make sure the following is
done:

1. Fork [the repository](https://github.com/facebook/react) and create
your branch from `main`.
  2. Run `yarn` in the repository root.
3. If you've fixed a bug or added code that should be tested, add tests!
4. Ensure the test suite passes (`yarn test`). Tip: `yarn test --watch
TestName` is helpful in development.
5. Run `yarn test --prod` to test in the production environment. It
supports the same options as `yarn test`.
6. If you need a debugger, run `yarn test --debug --watch TestName`,
open `chrome://inspect`, and press "Inspect".
7. Format your code with
[prettier](https://github.com/prettier/prettier) (`yarn prettier`).
8. Make sure your code lints (`yarn lint`). Tip: `yarn linc` to only
check changed files.
  9. Run the [Flow](https://flowtype.org/) type checks (`yarn flow`).
  10. If you haven't already, complete the CLA.

Learn more about contributing:
https://reactjs.org/docs/how-to-contribute.html
-->

## Summary

This PR adds support for displaying the names of changed hooks directly
in the Profiler tab, making it easier to identify specific updates.

A `HookChangeSummary` component has been introduced to show these hook
names, with a `displayMode` prop that toggles between `“compact”` for
tooltips and `“detailed”` for more in-depth views. This keeps tooltip
summaries concise while allowing for a full breakdown where needed.

This functionality also respects the `“Always parse hook names from
source”` setting from the Component inspector, as it uses the same
caching mechanism already in place for the Components tab. Additionally,
even without hook names parsed, the Profiler will now display hook types
(like `State`, `Callback`, etc.) based on data from `inspectedElement`.

To enable this across the DevTools, `InspectedElementContext` has been
moved higher in the component tree, allowing it to be shared between the
Profiler and Components tabs. This update allows hook name data to be
reused across tabs without duplication.

Additionally, a `getAlreadyLoadedHookNames` helper function was added to
efficiently access cached hook names, reducing the need for repeated
fetching when displaying changes.

These changes improve the ability to track specific hook updates within
the Profiler tab, making it clearer to see what’s changed.

### Before
Previously, the Profiler tab displayed only the IDs of changed hooks, as
shown below:
<img width="350" alt="Screenshot 2024-11-01 at 12 02 21_cropped"
src="https://github.com/user-attachments/assets/7a5f5f67-f1c8-4261-9ba3-1c76c9a88af3">

### After (without hook names parsed)
When hook names aren’t parsed, custom hooks and hook types are displayed
based on the inspectedElement data:
<img width="350" alt="Screenshot 2024-11-01 at 12 03 09_cropped"
src="https://github.com/user-attachments/assets/ed857a6d-e6ef-4e5b-982c-bf30c2d8a7e2">

### After (with hook names parsed)
Once hook names are fully parsed, the Profiler tab provides a complete
breakdown of specific hooks that have changed:
<img width="350" alt="Screenshot 2024-11-01 at 12 03 14_cropped"
src="https://github.com/user-attachments/assets/1ddfcc35-7474-4f4d-a084-f4e9f993a5bf">

This should resolve #21856 🎉
This commit is contained in:
Piotr Tomczewski 2025-04-15 12:10:00 +02:00 committed by GitHub
parent 08075929f2
commit 7ff4d057b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 327 additions and 59 deletions

View File

@ -19,7 +19,6 @@ import {
} from 'react-devtools-shared/src/storage';
import InspectedElementErrorBoundary from './InspectedElementErrorBoundary';
import InspectedElement from './InspectedElement';
import {InspectedElementContextController} from './InspectedElementContext';
import {ModalDialog} from '../ModalDialog';
import SettingsModal from 'react-devtools-shared/src/devtools/views/Settings/SettingsModal';
import {NativeStyleContextController} from './NativeStyleEditor/context';
@ -162,9 +161,7 @@ function Components(_: {}) {
<div className={styles.InspectedElementWrapper}>
<NativeStyleContextController>
<InspectedElementErrorBoundary>
<InspectedElementContextController>
<InspectedElement />
</InspectedElementContextController>
<InspectedElement />
</InspectedElementErrorBoundary>
</NativeStyleContextController>
</div>

View File

@ -28,6 +28,7 @@ import {SettingsContextController} from './Settings/SettingsContext';
import {TreeContextController} from './Components/TreeContext';
import ViewElementSourceContext from './Components/ViewElementSourceContext';
import FetchFileWithCachingContext from './Components/FetchFileWithCachingContext';
import {InspectedElementContextController} from './Components/InspectedElementContext';
import HookNamesModuleLoaderContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext';
import {ProfilerContextController} from './Profiler/ProfilerContext';
import {TimelineContextController} from 'react-devtools-timeline/src/TimelineContext';
@ -276,43 +277,47 @@ export default function DevTools({
<TreeContextController>
<ProfilerContextController>
<TimelineContextController>
<ThemeProvider>
<div
className={styles.DevTools}
ref={devToolsRef}
data-react-devtools-portal-root={true}>
{showTabBar && (
<div className={styles.TabBar}>
<ReactLogo />
<span className={styles.DevToolsVersion}>
{process.env.DEVTOOLS_VERSION}
</span>
<div className={styles.Spacer} />
<TabBar
currentTab={tab}
id="DevTools"
selectTab={selectTab}
tabs={tabs}
type="navigation"
<InspectedElementContextController>
<ThemeProvider>
<div
className={styles.DevTools}
ref={devToolsRef}
data-react-devtools-portal-root={true}>
{showTabBar && (
<div className={styles.TabBar}>
<ReactLogo />
<span className={styles.DevToolsVersion}>
{process.env.DEVTOOLS_VERSION}
</span>
<div className={styles.Spacer} />
<TabBar
currentTab={tab}
id="DevTools"
selectTab={selectTab}
tabs={tabs}
type="navigation"
/>
</div>
)}
<div
className={styles.TabContent}
hidden={tab !== 'components'}>
<Components
portalContainer={
componentsPortalContainer
}
/>
</div>
<div
className={styles.TabContent}
hidden={tab !== 'profiler'}>
<Profiler
portalContainer={profilerPortalContainer}
/>
</div>
)}
<div
className={styles.TabContent}
hidden={tab !== 'components'}>
<Components
portalContainer={componentsPortalContainer}
/>
</div>
<div
className={styles.TabContent}
hidden={tab !== 'profiler'}>
<Profiler
portalContainer={profilerPortalContainer}
/>
</div>
</div>
</ThemeProvider>
</ThemeProvider>
</InspectedElementContextController>
</TimelineContextController>
</ProfilerContextController>
</TreeContextController>

View File

@ -0,0 +1,59 @@
.LoadHookNamesToggle,
.ToggleError {
padding: 2px;
background: none;
border: none;
cursor: pointer;
position: relative;
bottom: -0.2em;
margin-block: -1em;
}
.ToggleError {
color: var(--color-error-text);
}
.Hook {
list-style-type: none;
margin: 0;
padding-left: 0.5rem;
line-height: 1.125rem;
font-family: var(--font-family-monospace);
font-size: var(--font-size-monospace-normal);
}
.Hook .Hook {
padding-left: 1rem;
}
.Name {
color: var(--color-dim);
flex: 0 0 auto;
cursor: default;
}
.PrimitiveHookName {
color: var(--color-text);
flex: 0 0 auto;
cursor: default;
}
.Name:after {
color: var(--color-text);
content: ': ';
margin-right: 0.5rem;
}
.PrimitiveHookNumber {
background-color: var(--color-primitive-hook-badge-background);
color: var(--color-primitive-hook-badge-text);
font-size: var(--font-size-monospace-small);
margin-right: 0.25rem;
border-radius: 0.125rem;
padding: 0.125rem 0.25rem;
}
.HookName {
color: var(--color-component-name);
}

View File

@ -0,0 +1,207 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import * as React from 'react';
import {
useContext,
useMemo,
useCallback,
memo,
useState,
useEffect,
} from 'react';
import styles from './HookChangeSummary.css';
import ButtonIcon from '../ButtonIcon';
import {InspectedElementContext} from '../Components/InspectedElementContext';
import {StoreContext} from '../context';
import {
getAlreadyLoadedHookNames,
getHookSourceLocationKey,
} from 'react-devtools-shared/src/hookNamesCache';
import Toggle from '../Toggle';
import type {HooksNode} from 'react-debug-tools/src/ReactDebugHooks';
import type {ChangeDescription} from './types';
// $FlowFixMe: Flow doesn't know about Intl.ListFormat
const hookListFormatter = new Intl.ListFormat('en', {
style: 'long',
type: 'conjunction',
});
type HookProps = {
hook: HooksNode,
hookNames: Map<string, string> | null,
};
const Hook: React.AbstractComponent<HookProps> = memo(({hook, hookNames}) => {
const hookSource = hook.hookSource;
const hookName = useMemo(() => {
if (!hookSource || !hookNames) return null;
const key = getHookSourceLocationKey(hookSource);
return hookNames.get(key) || null;
}, [hookSource, hookNames]);
return (
<ul className={styles.Hook}>
<li>
{hook.id !== null && (
<span className={styles.PrimitiveHookNumber}>
{String(hook.id + 1)}
</span>
)}
<span
className={hook.id !== null ? styles.PrimitiveHookName : styles.Name}>
{hook.name}
{hookName && <span className={styles.HookName}>({hookName})</span>}
</span>
{hook.subHooks?.map((subHook, index) => (
<Hook key={hook.id} hook={subHook} hookNames={hookNames} />
))}
</li>
</ul>
);
});
const shouldKeepHook = (
hook: HooksNode,
hooksArray: Array<number>,
): boolean => {
if (hook.id !== null && hooksArray.includes(hook.id)) {
return true;
}
const subHooks = hook.subHooks;
if (subHooks == null) {
return false;
}
return subHooks.some(subHook => shouldKeepHook(subHook, hooksArray));
};
const filterHooks = (
hook: HooksNode,
hooksArray: Array<number>,
): HooksNode | null => {
if (!shouldKeepHook(hook, hooksArray)) {
return null;
}
const subHooks = hook.subHooks;
if (subHooks == null) {
return hook;
}
const filteredSubHooks = subHooks
.map(subHook => filterHooks(subHook, hooksArray))
.filter(Boolean);
return filteredSubHooks.length > 0
? {...hook, subHooks: filteredSubHooks}
: hook;
};
type Props = {|
fiberID: number,
hooks: $PropertyType<ChangeDescription, 'hooks'>,
state: $PropertyType<ChangeDescription, 'state'>,
displayMode?: 'detailed' | 'compact',
|};
const HookChangeSummary: React.AbstractComponent<Props> = memo(
({hooks, fiberID, state, displayMode = 'detailed'}: Props) => {
const {parseHookNames, toggleParseHookNames, inspectedElement} = useContext(
InspectedElementContext,
);
const store = useContext(StoreContext);
const [parseHookNamesOptimistic, setParseHookNamesOptimistic] =
useState<boolean>(parseHookNames);
useEffect(() => {
setParseHookNamesOptimistic(parseHookNames);
}, [inspectedElement?.id, parseHookNames]);
const handleOnChange = useCallback(() => {
setParseHookNamesOptimistic(!parseHookNames);
toggleParseHookNames();
}, [toggleParseHookNames, parseHookNames]);
const element = fiberID !== null ? store.getElementByID(fiberID) : null;
const hookNames =
element != null ? getAlreadyLoadedHookNames(element) : null;
const filteredHooks = useMemo(() => {
if (!hooks || !inspectedElement?.hooks) return null;
return inspectedElement.hooks
.map(hook => filterHooks(hook, hooks))
.filter(Boolean);
}, [inspectedElement?.hooks, hooks]);
const hookParsingFailed = parseHookNames && hookNames === null;
if (!hooks?.length) {
return <span>No hooks changed</span>;
}
if (
inspectedElement?.id !== element?.id ||
filteredHooks?.length !== hooks.length ||
displayMode === 'compact'
) {
const hookIds = hooks.map(hookId => String(hookId + 1));
const hookWord = hookIds.length === 1 ? '• Hook' : '• Hooks';
return (
<span>
{hookWord} {hookListFormatter.format(hookIds)} changed
</span>
);
}
let toggleTitle: string;
if (hookParsingFailed) {
toggleTitle = 'Hook parsing failed';
} else if (parseHookNamesOptimistic) {
toggleTitle = 'Parsing hook names ...';
} else {
toggleTitle = 'Parse hook names (may be slow)';
}
if (filteredHooks == null) {
return null;
}
return (
<div>
{filteredHooks.length > 1 ? '• Hooks changed:' : '• Hook changed:'}
{(!parseHookNames || hookParsingFailed) && (
<Toggle
className={
hookParsingFailed
? styles.ToggleError
: styles.LoadHookNamesToggle
}
isChecked={parseHookNamesOptimistic}
isDisabled={parseHookNamesOptimistic || hookParsingFailed}
onChange={handleOnChange}
title={toggleTitle}>
<ButtonIcon type="parse-hook-names" />
</Toggle>
)}
{filteredHooks.map(hook => (
<Hook
key={`${inspectedElement?.id ?? 'unknown'}-${hook.id}`}
hook={hook}
hookNames={hookNames}
/>
))}
</div>
);
},
);
export default HookChangeSummary;

View File

@ -95,7 +95,7 @@ export default function HoveredFiberInfo({fiberData}: Props): React.Node {
<div className={styles.Content}>
{renderDurationInfo || <div>Did not client render.</div>}
<WhatChanged fiberID={id} />
<WhatChanged fiberID={id} displayMode="compact" />
</div>
</div>
</Fragment>

View File

@ -14,30 +14,17 @@ import {ProfilerContext} from './ProfilerContext';
import {StoreContext} from '../context';
import styles from './WhatChanged.css';
function hookIndicesToString(indices: Array<number>): string {
// This is debatable but I think 1-based might ake for a nicer UX.
const numbers = indices.map(value => value + 1);
switch (numbers.length) {
case 0:
return 'No hooks changed';
case 1:
return `Hook ${numbers[0]} changed`;
case 2:
return `Hooks ${numbers[0]} and ${numbers[1]} changed`;
default:
return `Hooks ${numbers.slice(0, numbers.length - 1).join(', ')} and ${
numbers[numbers.length - 1]
} changed`;
}
}
import HookChangeSummary from './HookChangeSummary';
type Props = {
fiberID: number,
displayMode?: 'detailed' | 'compact',
};
export default function WhatChanged({fiberID}: Props): React.Node {
export default function WhatChanged({
fiberID,
displayMode = 'detailed',
}: Props): React.Node {
const {profilerStore} = useContext(StoreContext);
const {rootID, selectedCommitIndex} = useContext(ProfilerContext);
@ -106,7 +93,12 @@ export default function WhatChanged({fiberID}: Props): React.Node {
if (Array.isArray(hooks)) {
changes.push(
<div key="hooks" className={styles.Item}>
{hookIndicesToString(hooks)}
<HookChangeSummary
hooks={hooks}
fiberID={fiberID}
state={state}
displayMode={displayMode}
/>
</div>,
);
} else {

View File

@ -72,6 +72,14 @@ export function hasAlreadyLoadedHookNames(element: Element): boolean {
return record != null && record.status === Resolved;
}
export function getAlreadyLoadedHookNames(element: Element): HookNames | null {
const record = map.get(element);
if (record != null && record.status === Resolved) {
return record.value;
}
return null;
}
export function loadHookNames(
element: Element,
hooksTree: HooksTree,