diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Components.js b/packages/react-devtools-shared/src/devtools/views/Components/Components.js
index 995f1d92cf323..b7ce607685051 100644
--- a/packages/react-devtools-shared/src/devtools/views/Components/Components.js
+++ b/packages/react-devtools-shared/src/devtools/views/Components/Components.js
@@ -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(_: {}) {
-
-
-
+
diff --git a/packages/react-devtools-shared/src/devtools/views/DevTools.js b/packages/react-devtools-shared/src/devtools/views/DevTools.js
index 9bee64ff95150..bd14bdda0f5ba 100644
--- a/packages/react-devtools-shared/src/devtools/views/DevTools.js
+++ b/packages/react-devtools-shared/src/devtools/views/DevTools.js
@@ -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({
-
-
- {showTabBar && (
-
-
-
- {process.env.DEVTOOLS_VERSION}
-
-
-
+
+
+ {showTabBar && (
+
+
+
+ {process.env.DEVTOOLS_VERSION}
+
+
+
+
+ )}
+
+
+
+
- )}
-
-
-
-
-
-
+
+
diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/HookChangeSummary.css b/packages/react-devtools-shared/src/devtools/views/Profiler/HookChangeSummary.css
new file mode 100644
index 0000000000000..d1aff47bddfcf
--- /dev/null
+++ b/packages/react-devtools-shared/src/devtools/views/Profiler/HookChangeSummary.css
@@ -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);
+}
diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/HookChangeSummary.js b/packages/react-devtools-shared/src/devtools/views/Profiler/HookChangeSummary.js
new file mode 100644
index 0000000000000..ed852c85c778a
--- /dev/null
+++ b/packages/react-devtools-shared/src/devtools/views/Profiler/HookChangeSummary.js
@@ -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 | null,
+};
+
+const Hook: React.AbstractComponent = 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 (
+
+ -
+ {hook.id !== null && (
+
+ {String(hook.id + 1)}
+
+ )}
+
+ {hook.name}
+ {hookName && ({hookName})}
+
+ {hook.subHooks?.map((subHook, index) => (
+
+ ))}
+
+
+ );
+});
+
+const shouldKeepHook = (
+ hook: HooksNode,
+ hooksArray: Array,
+): 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,
+): 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,
+ state: $PropertyType,
+ displayMode?: 'detailed' | 'compact',
+|};
+
+const HookChangeSummary: React.AbstractComponent = memo(
+ ({hooks, fiberID, state, displayMode = 'detailed'}: Props) => {
+ const {parseHookNames, toggleParseHookNames, inspectedElement} = useContext(
+ InspectedElementContext,
+ );
+ const store = useContext(StoreContext);
+
+ const [parseHookNamesOptimistic, setParseHookNamesOptimistic] =
+ useState(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 No hooks changed;
+ }
+
+ 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 (
+
+ {hookWord} {hookListFormatter.format(hookIds)} changed
+
+ );
+ }
+
+ 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 (
+
+ {filteredHooks.length > 1 ? '• Hooks changed:' : '• Hook changed:'}
+ {(!parseHookNames || hookParsingFailed) && (
+
+
+
+ )}
+ {filteredHooks.map(hook => (
+
+ ))}
+
+ );
+ },
+);
+
+export default HookChangeSummary;
diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/HoveredFiberInfo.js b/packages/react-devtools-shared/src/devtools/views/Profiler/HoveredFiberInfo.js
index f1156a05aee14..a6dfeffb3e20e 100644
--- a/packages/react-devtools-shared/src/devtools/views/Profiler/HoveredFiberInfo.js
+++ b/packages/react-devtools-shared/src/devtools/views/Profiler/HoveredFiberInfo.js
@@ -95,7 +95,7 @@ export default function HoveredFiberInfo({fiberData}: Props): React.Node {
{renderDurationInfo ||
Did not client render.
}
-
+
diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/WhatChanged.js b/packages/react-devtools-shared/src/devtools/views/Profiler/WhatChanged.js
index 52fc756a063d1..dffcd03350cda 100644
--- a/packages/react-devtools-shared/src/devtools/views/Profiler/WhatChanged.js
+++ b/packages/react-devtools-shared/src/devtools/views/Profiler/WhatChanged.js
@@ -14,30 +14,17 @@ import {ProfilerContext} from './ProfilerContext';
import {StoreContext} from '../context';
import styles from './WhatChanged.css';
-
-function hookIndicesToString(indices: Array
): 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(
- • {hookIndicesToString(hooks)}
+
,
);
} else {
diff --git a/packages/react-devtools-shared/src/hookNamesCache.js b/packages/react-devtools-shared/src/hookNamesCache.js
index 89377eebb18fd..e0d4b1075c219 100644
--- a/packages/react-devtools-shared/src/hookNamesCache.js
+++ b/packages/react-devtools-shared/src/hookNamesCache.js
@@ -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,