react/packages/react-devtools-shared/src/hookNamesCache.js
Piotr Tomczewski 7ff4d057b6
[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 🎉
2025-04-15 11:10:00 +01:00

232 lines
6.2 KiB
JavaScript

/**
* 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 {__DEBUG__} from 'react-devtools-shared/src/constants';
import type {HooksTree} from 'react-debug-tools/src/ReactDebugHooks';
import type {Thenable, Wakeable} from 'shared/ReactTypes';
import type {
Element,
HookNames,
HookSourceLocationKey,
} from 'react-devtools-shared/src/frontend/types';
import type {HookSource} from 'react-debug-tools/src/ReactDebugHooks';
import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/Components/FetchFileWithCachingContext';
import {withCallbackPerfMeasurements} from './PerformanceLoggingUtils';
import {logEvent} from './Logger';
const TIMEOUT = 30000;
const Pending = 0;
const Resolved = 1;
const Rejected = 2;
type PendingRecord = {
status: 0,
value: Wakeable,
};
type ResolvedRecord<T> = {
status: 1,
value: T,
};
type RejectedRecord = {
status: 2,
value: null,
};
type Record<T> = PendingRecord | ResolvedRecord<T> | RejectedRecord;
function readRecord<T>(record: Record<T>): ResolvedRecord<T> | RejectedRecord {
if (record.status === Resolved) {
// This is just a type refinement.
return record;
} else if (record.status === Rejected) {
// This is just a type refinement.
return record;
} else {
throw record.value;
}
}
type LoadHookNamesFunction = (
hookLog: HooksTree,
fetchFileWithCaching: FetchFileWithCaching | null,
) => Thenable<HookNames>;
// This is intentionally a module-level Map, rather than a React-managed one.
// Otherwise, refreshing the inspected element cache would also clear this cache.
// TODO Rethink this if the React API constraints change.
// See https://github.com/reactwg/react-18/discussions/25#discussioncomment-980435
let map: WeakMap<Element, Record<HookNames>> = new WeakMap();
export function hasAlreadyLoadedHookNames(element: Element): boolean {
const record = map.get(element);
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,
loadHookNamesFunction: LoadHookNamesFunction,
fetchFileWithCaching: FetchFileWithCaching | null,
): HookNames | null {
let record = map.get(element);
if (__DEBUG__) {
console.groupCollapsed('loadHookNames() record:');
console.log(record);
console.groupEnd();
}
if (!record) {
const callbacks = new Set<() => mixed>();
const wakeable: Wakeable = {
then(callback: () => mixed) {
callbacks.add(callback);
},
// Optional property used by Timeline:
displayName: `Loading hook names for ${element.displayName || 'Unknown'}`,
};
let timeoutID: $FlowFixMe | null;
let didTimeout = false;
let status = 'unknown';
let resolvedHookNames: HookNames | null = null;
const wake = () => {
if (timeoutID) {
clearTimeout(timeoutID);
timeoutID = null;
}
// This assumes they won't throw.
callbacks.forEach(callback => callback());
callbacks.clear();
};
const handleLoadComplete = (durationMs: number): void => {
// Log duration for parsing hook names
logEvent({
event_name: 'load-hook-names',
event_status: status,
duration_ms: durationMs,
inspected_element_display_name: element.displayName,
inspected_element_number_of_hooks: resolvedHookNames?.size ?? null,
});
};
const newRecord: Record<HookNames> = (record = {
status: Pending,
value: wakeable,
});
withCallbackPerfMeasurements(
'loadHookNames',
done => {
loadHookNamesFunction(hooksTree, fetchFileWithCaching).then(
function onSuccess(hookNames) {
if (didTimeout) {
return;
}
if (__DEBUG__) {
console.log('[hookNamesCache] onSuccess() hookNames:', hookNames);
}
if (hookNames) {
const resolvedRecord =
((newRecord: any): ResolvedRecord<HookNames>);
resolvedRecord.status = Resolved;
resolvedRecord.value = hookNames;
} else {
const notFoundRecord = ((newRecord: any): RejectedRecord);
notFoundRecord.status = Rejected;
notFoundRecord.value = null;
}
status = 'success';
resolvedHookNames = hookNames;
done();
wake();
},
function onError(error) {
if (didTimeout) {
return;
}
if (__DEBUG__) {
console.log('[hookNamesCache] onError()');
}
console.error(error);
const thrownRecord = ((newRecord: any): RejectedRecord);
thrownRecord.status = Rejected;
thrownRecord.value = null;
status = 'error';
done();
wake();
},
);
// Eventually timeout and stop trying to load names.
timeoutID = setTimeout(function onTimeout() {
if (__DEBUG__) {
console.log('[hookNamesCache] onTimeout()');
}
timeoutID = null;
didTimeout = true;
const timedoutRecord = ((newRecord: any): RejectedRecord);
timedoutRecord.status = Rejected;
timedoutRecord.value = null;
status = 'timeout';
done();
wake();
}, TIMEOUT);
},
handleLoadComplete,
);
map.set(element, record);
}
const response = readRecord(record).value;
return response;
}
export function getHookSourceLocationKey({
fileName,
lineNumber,
columnNumber,
}: HookSource): HookSourceLocationKey {
if (fileName == null || lineNumber == null || columnNumber == null) {
throw Error('Hook source code location not found.');
}
return `${fileName}:${lineNumber}:${columnNumber}`;
}
export function clearHookNamesCache(): void {
map = new WeakMap();
}