Skip to content

Commit 7ff4d05

Browse files
authored
[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 🎉
1 parent 0807592 commit 7ff4d05

File tree

7 files changed

+327
-59
lines changed

7 files changed

+327
-59
lines changed

Diff for: packages/react-devtools-shared/src/devtools/views/Components/Components.js

+1-4
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
} from 'react-devtools-shared/src/storage';
2020
import InspectedElementErrorBoundary from './InspectedElementErrorBoundary';
2121
import InspectedElement from './InspectedElement';
22-
import {InspectedElementContextController} from './InspectedElementContext';
2322
import {ModalDialog} from '../ModalDialog';
2423
import SettingsModal from 'react-devtools-shared/src/devtools/views/Settings/SettingsModal';
2524
import {NativeStyleContextController} from './NativeStyleEditor/context';
@@ -162,9 +161,7 @@ function Components(_: {}) {
162161
<div className={styles.InspectedElementWrapper}>
163162
<NativeStyleContextController>
164163
<InspectedElementErrorBoundary>
165-
<InspectedElementContextController>
166-
<InspectedElement />
167-
</InspectedElementContextController>
164+
<InspectedElement />
168165
</InspectedElementErrorBoundary>
169166
</NativeStyleContextController>
170167
</div>

Diff for: packages/react-devtools-shared/src/devtools/views/DevTools.js

+39-34
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {SettingsContextController} from './Settings/SettingsContext';
2828
import {TreeContextController} from './Components/TreeContext';
2929
import ViewElementSourceContext from './Components/ViewElementSourceContext';
3030
import FetchFileWithCachingContext from './Components/FetchFileWithCachingContext';
31+
import {InspectedElementContextController} from './Components/InspectedElementContext';
3132
import HookNamesModuleLoaderContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext';
3233
import {ProfilerContextController} from './Profiler/ProfilerContext';
3334
import {TimelineContextController} from 'react-devtools-timeline/src/TimelineContext';
@@ -276,43 +277,47 @@ export default function DevTools({
276277
<TreeContextController>
277278
<ProfilerContextController>
278279
<TimelineContextController>
279-
<ThemeProvider>
280-
<div
281-
className={styles.DevTools}
282-
ref={devToolsRef}
283-
data-react-devtools-portal-root={true}>
284-
{showTabBar && (
285-
<div className={styles.TabBar}>
286-
<ReactLogo />
287-
<span className={styles.DevToolsVersion}>
288-
{process.env.DEVTOOLS_VERSION}
289-
</span>
290-
<div className={styles.Spacer} />
291-
<TabBar
292-
currentTab={tab}
293-
id="DevTools"
294-
selectTab={selectTab}
295-
tabs={tabs}
296-
type="navigation"
280+
<InspectedElementContextController>
281+
<ThemeProvider>
282+
<div
283+
className={styles.DevTools}
284+
ref={devToolsRef}
285+
data-react-devtools-portal-root={true}>
286+
{showTabBar && (
287+
<div className={styles.TabBar}>
288+
<ReactLogo />
289+
<span className={styles.DevToolsVersion}>
290+
{process.env.DEVTOOLS_VERSION}
291+
</span>
292+
<div className={styles.Spacer} />
293+
<TabBar
294+
currentTab={tab}
295+
id="DevTools"
296+
selectTab={selectTab}
297+
tabs={tabs}
298+
type="navigation"
299+
/>
300+
</div>
301+
)}
302+
<div
303+
className={styles.TabContent}
304+
hidden={tab !== 'components'}>
305+
<Components
306+
portalContainer={
307+
componentsPortalContainer
308+
}
309+
/>
310+
</div>
311+
<div
312+
className={styles.TabContent}
313+
hidden={tab !== 'profiler'}>
314+
<Profiler
315+
portalContainer={profilerPortalContainer}
297316
/>
298317
</div>
299-
)}
300-
<div
301-
className={styles.TabContent}
302-
hidden={tab !== 'components'}>
303-
<Components
304-
portalContainer={componentsPortalContainer}
305-
/>
306-
</div>
307-
<div
308-
className={styles.TabContent}
309-
hidden={tab !== 'profiler'}>
310-
<Profiler
311-
portalContainer={profilerPortalContainer}
312-
/>
313318
</div>
314-
</div>
315-
</ThemeProvider>
319+
</ThemeProvider>
320+
</InspectedElementContextController>
316321
</TimelineContextController>
317322
</ProfilerContextController>
318323
</TreeContextController>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
.LoadHookNamesToggle,
2+
.ToggleError {
3+
padding: 2px;
4+
background: none;
5+
border: none;
6+
cursor: pointer;
7+
position: relative;
8+
bottom: -0.2em;
9+
margin-block: -1em;
10+
}
11+
12+
.ToggleError {
13+
color: var(--color-error-text);
14+
}
15+
16+
.Hook {
17+
list-style-type: none;
18+
margin: 0;
19+
padding-left: 0.5rem;
20+
line-height: 1.125rem;
21+
22+
font-family: var(--font-family-monospace);
23+
font-size: var(--font-size-monospace-normal);
24+
}
25+
26+
.Hook .Hook {
27+
padding-left: 1rem;
28+
}
29+
30+
.Name {
31+
color: var(--color-dim);
32+
flex: 0 0 auto;
33+
cursor: default;
34+
}
35+
36+
.PrimitiveHookName {
37+
color: var(--color-text);
38+
flex: 0 0 auto;
39+
cursor: default;
40+
}
41+
42+
.Name:after {
43+
color: var(--color-text);
44+
content: ': ';
45+
margin-right: 0.5rem;
46+
}
47+
48+
.PrimitiveHookNumber {
49+
background-color: var(--color-primitive-hook-badge-background);
50+
color: var(--color-primitive-hook-badge-text);
51+
font-size: var(--font-size-monospace-small);
52+
margin-right: 0.25rem;
53+
border-radius: 0.125rem;
54+
padding: 0.125rem 0.25rem;
55+
}
56+
57+
.HookName {
58+
color: var(--color-component-name);
59+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
import * as React from 'react';
11+
import {
12+
useContext,
13+
useMemo,
14+
useCallback,
15+
memo,
16+
useState,
17+
useEffect,
18+
} from 'react';
19+
import styles from './HookChangeSummary.css';
20+
import ButtonIcon from '../ButtonIcon';
21+
import {InspectedElementContext} from '../Components/InspectedElementContext';
22+
import {StoreContext} from '../context';
23+
24+
import {
25+
getAlreadyLoadedHookNames,
26+
getHookSourceLocationKey,
27+
} from 'react-devtools-shared/src/hookNamesCache';
28+
import Toggle from '../Toggle';
29+
import type {HooksNode} from 'react-debug-tools/src/ReactDebugHooks';
30+
import type {ChangeDescription} from './types';
31+
32+
// $FlowFixMe: Flow doesn't know about Intl.ListFormat
33+
const hookListFormatter = new Intl.ListFormat('en', {
34+
style: 'long',
35+
type: 'conjunction',
36+
});
37+
38+
type HookProps = {
39+
hook: HooksNode,
40+
hookNames: Map<string, string> | null,
41+
};
42+
43+
const Hook: React.AbstractComponent<HookProps> = memo(({hook, hookNames}) => {
44+
const hookSource = hook.hookSource;
45+
const hookName = useMemo(() => {
46+
if (!hookSource || !hookNames) return null;
47+
const key = getHookSourceLocationKey(hookSource);
48+
return hookNames.get(key) || null;
49+
}, [hookSource, hookNames]);
50+
51+
return (
52+
<ul className={styles.Hook}>
53+
<li>
54+
{hook.id !== null && (
55+
<span className={styles.PrimitiveHookNumber}>
56+
{String(hook.id + 1)}
57+
</span>
58+
)}
59+
<span
60+
className={hook.id !== null ? styles.PrimitiveHookName : styles.Name}>
61+
{hook.name}
62+
{hookName && <span className={styles.HookName}>({hookName})</span>}
63+
</span>
64+
{hook.subHooks?.map((subHook, index) => (
65+
<Hook key={hook.id} hook={subHook} hookNames={hookNames} />
66+
))}
67+
</li>
68+
</ul>
69+
);
70+
});
71+
72+
const shouldKeepHook = (
73+
hook: HooksNode,
74+
hooksArray: Array<number>,
75+
): boolean => {
76+
if (hook.id !== null && hooksArray.includes(hook.id)) {
77+
return true;
78+
}
79+
const subHooks = hook.subHooks;
80+
if (subHooks == null) {
81+
return false;
82+
}
83+
84+
return subHooks.some(subHook => shouldKeepHook(subHook, hooksArray));
85+
};
86+
87+
const filterHooks = (
88+
hook: HooksNode,
89+
hooksArray: Array<number>,
90+
): HooksNode | null => {
91+
if (!shouldKeepHook(hook, hooksArray)) {
92+
return null;
93+
}
94+
95+
const subHooks = hook.subHooks;
96+
if (subHooks == null) {
97+
return hook;
98+
}
99+
100+
const filteredSubHooks = subHooks
101+
.map(subHook => filterHooks(subHook, hooksArray))
102+
.filter(Boolean);
103+
return filteredSubHooks.length > 0
104+
? {...hook, subHooks: filteredSubHooks}
105+
: hook;
106+
};
107+
108+
type Props = {|
109+
fiberID: number,
110+
hooks: $PropertyType<ChangeDescription, 'hooks'>,
111+
state: $PropertyType<ChangeDescription, 'state'>,
112+
displayMode?: 'detailed' | 'compact',
113+
|};
114+
115+
const HookChangeSummary: React.AbstractComponent<Props> = memo(
116+
({hooks, fiberID, state, displayMode = 'detailed'}: Props) => {
117+
const {parseHookNames, toggleParseHookNames, inspectedElement} = useContext(
118+
InspectedElementContext,
119+
);
120+
const store = useContext(StoreContext);
121+
122+
const [parseHookNamesOptimistic, setParseHookNamesOptimistic] =
123+
useState<boolean>(parseHookNames);
124+
125+
useEffect(() => {
126+
setParseHookNamesOptimistic(parseHookNames);
127+
}, [inspectedElement?.id, parseHookNames]);
128+
129+
const handleOnChange = useCallback(() => {
130+
setParseHookNamesOptimistic(!parseHookNames);
131+
toggleParseHookNames();
132+
}, [toggleParseHookNames, parseHookNames]);
133+
134+
const element = fiberID !== null ? store.getElementByID(fiberID) : null;
135+
const hookNames =
136+
element != null ? getAlreadyLoadedHookNames(element) : null;
137+
138+
const filteredHooks = useMemo(() => {
139+
if (!hooks || !inspectedElement?.hooks) return null;
140+
return inspectedElement.hooks
141+
.map(hook => filterHooks(hook, hooks))
142+
.filter(Boolean);
143+
}, [inspectedElement?.hooks, hooks]);
144+
145+
const hookParsingFailed = parseHookNames && hookNames === null;
146+
147+
if (!hooks?.length) {
148+
return <span>No hooks changed</span>;
149+
}
150+
151+
if (
152+
inspectedElement?.id !== element?.id ||
153+
filteredHooks?.length !== hooks.length ||
154+
displayMode === 'compact'
155+
) {
156+
const hookIds = hooks.map(hookId => String(hookId + 1));
157+
const hookWord = hookIds.length === 1 ? '• Hook' : '• Hooks';
158+
return (
159+
<span>
160+
{hookWord} {hookListFormatter.format(hookIds)} changed
161+
</span>
162+
);
163+
}
164+
165+
let toggleTitle: string;
166+
if (hookParsingFailed) {
167+
toggleTitle = 'Hook parsing failed';
168+
} else if (parseHookNamesOptimistic) {
169+
toggleTitle = 'Parsing hook names ...';
170+
} else {
171+
toggleTitle = 'Parse hook names (may be slow)';
172+
}
173+
174+
if (filteredHooks == null) {
175+
return null;
176+
}
177+
178+
return (
179+
<div>
180+
{filteredHooks.length > 1 ? '• Hooks changed:' : '• Hook changed:'}
181+
{(!parseHookNames || hookParsingFailed) && (
182+
<Toggle
183+
className={
184+
hookParsingFailed
185+
? styles.ToggleError
186+
: styles.LoadHookNamesToggle
187+
}
188+
isChecked={parseHookNamesOptimistic}
189+
isDisabled={parseHookNamesOptimistic || hookParsingFailed}
190+
onChange={handleOnChange}
191+
title={toggleTitle}>
192+
<ButtonIcon type="parse-hook-names" />
193+
</Toggle>
194+
)}
195+
{filteredHooks.map(hook => (
196+
<Hook
197+
key={`${inspectedElement?.id ?? 'unknown'}-${hook.id}`}
198+
hook={hook}
199+
hookNames={hookNames}
200+
/>
201+
))}
202+
</div>
203+
);
204+
},
205+
);
206+
207+
export default HookChangeSummary;

Diff for: packages/react-devtools-shared/src/devtools/views/Profiler/HoveredFiberInfo.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export default function HoveredFiberInfo({fiberData}: Props): React.Node {
9595
<div className={styles.Content}>
9696
{renderDurationInfo || <div>Did not client render.</div>}
9797

98-
<WhatChanged fiberID={id} />
98+
<WhatChanged fiberID={id} displayMode="compact" />
9999
</div>
100100
</div>
101101
</Fragment>

0 commit comments

Comments
 (0)