mirror of
https://github.com/zebrajr/react.git
synced 2025-12-06 00:20:04 +01:00
[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:
parent
08075929f2
commit
7ff4d057b6
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
207
packages/react-devtools-shared/src/devtools/views/Profiler/HookChangeSummary.js
vendored
Normal file
207
packages/react-devtools-shared/src/devtools/views/Profiler/HookChangeSummary.js
vendored
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user