[DevTools] Show component names while highlighting renders (#31577)

## Summary
This PR improves the Trace Updates feature by letting developers see
component names directly on the update overlay. Before this change, the
overlay only highlighted updated regions, leaving it unclear which
components were involved. With this update, you can now match visual
updates to their corresponding components, making it much easier to
debug rendering performance.

### New Feature: Show component names while highlighting
When the new **"Show component names while highlighting"** setting is
enabled, the update overlay display the names of affected components
above the rectangles, along with the update count. This gives immediate
context about what’s rendering and why. The preference is stored in
local storage and synced with the backend, so it’s remembered across
sessions.

### Improvements to Drawing Logic
The drawing logic has been updated to make the overlay sharper and
easier to read. Overlay now respect device pixel ratios, so they look
great on high-DPI screens. Outlines have also been made crisper, which
makes it easier to spot exactly where updates are happening.

> [!NOTE]
> **Grouping Logic and Limitations**
> Updates are grouped by their screen position `(left, top coordinates)`
to combine overlapping or nearby regions into a single group. Groups are
sorted by the highest update count within each group, making the most
frequently updated components stand out.
> Overlapping labels may still occur when multiple updates involve
components that overlap but are not in the exact same position. This is
intentional, as the logic aims to maintain a straightforward mapping
between update regions and component names without introducing
unnecessary complexity.

### Testing
This PR also adds tests for the new `groupAndSortNodes` utility, which
handles the logic for grouping and sorting updates. The tests ensure the
behavior is reliable across different scenarios.

## Before & After


https://github.com/user-attachments/assets/6ea0fe3e-9354-44fa-95f3-9a867554f74c


https://github.com/user-attachments/assets/32af4d98-92a5-47dd-a732-f05c2293e41b

---------

Co-authored-by: Ruslan Lesiutin <rdlesyutin@gmail.com>
This commit is contained in:
Piotr Tomczewski
2024-12-13 12:53:05 +01:00
committed by GitHub
parent 56ae4b8d22
commit a7b829524b
10 changed files with 501 additions and 36 deletions

View File

@@ -0,0 +1,269 @@
import {groupAndSortNodes} from 'react-devtools-shared/src/backend/views/TraceUpdates/canvas';
describe('Trace updates group and sort nodes', () => {
test('should group nodes by position without changing order within group', () => {
const nodeToData = new Map([
[
{id: 1},
{
rect: {left: 0, top: 0, width: 100, height: 100},
color: '#80b393',
displayName: 'Node1',
count: 3,
},
],
[
{id: 2},
{
rect: {left: 0, top: 0, width: 100, height: 100},
color: '#63b19e',
displayName: 'Node2',
count: 2,
},
],
]);
const result = groupAndSortNodes(nodeToData);
expect(result).toEqual([
[
{
rect: {left: 0, top: 0, width: 100, height: 100},
color: '#80b393',
displayName: 'Node1',
count: 3,
},
{
rect: {left: 0, top: 0, width: 100, height: 100},
color: '#63b19e',
displayName: 'Node2',
count: 2,
},
],
]);
});
test('should sort groups by lowest count in each group', () => {
const nodeToData = new Map([
[
{id: 1},
{
rect: {left: 0, top: 0, width: 100, height: 100},
color: '#97b488',
displayName: 'Group1',
count: 4,
},
],
[
{id: 2},
{
rect: {left: 100, top: 0, width: 100, height: 100},
color: '#37afa9',
displayName: 'Group2',
count: 1,
},
],
[
{id: 3},
{
rect: {left: 200, top: 0, width: 100, height: 100},
color: '#63b19e',
displayName: 'Group3',
count: 2,
},
],
]);
const result = groupAndSortNodes(nodeToData);
expect(result).toEqual([
[
{
rect: {left: 100, top: 0, width: 100, height: 100},
color: '#37afa9',
displayName: 'Group2',
count: 1,
},
],
[
{
rect: {left: 200, top: 0, width: 100, height: 100},
color: '#63b19e',
displayName: 'Group3',
count: 2,
},
],
[
{
rect: {left: 0, top: 0, width: 100, height: 100},
color: '#97b488',
displayName: 'Group1',
count: 4,
},
],
]);
});
test('should maintain order within groups while sorting groups by lowest count', () => {
const nodeToData = new Map([
[
{id: 1},
{
rect: {left: 0, top: 0, width: 50, height: 50},
color: '#97b488',
displayName: 'Pos1Node1',
count: 4,
},
],
[
{id: 2},
{
rect: {left: 0, top: 0, width: 60, height: 60},
color: '#63b19e',
displayName: 'Pos1Node2',
count: 2,
},
],
[
{id: 3},
{
rect: {left: 100, top: 0, width: 70, height: 70},
color: '#80b393',
displayName: 'Pos2Node1',
count: 3,
},
],
[
{id: 4},
{
rect: {left: 100, top: 0, width: 80, height: 80},
color: '#37afa9',
displayName: 'Pos2Node2',
count: 1,
},
],
]);
const result = groupAndSortNodes(nodeToData);
expect(result).toEqual([
[
{
rect: {left: 100, top: 0, width: 70, height: 70},
color: '#80b393',
displayName: 'Pos2Node1',
count: 3,
},
{
rect: {left: 100, top: 0, width: 80, height: 80},
color: '#37afa9',
displayName: 'Pos2Node2',
count: 1,
},
],
[
{
rect: {left: 0, top: 0, width: 50, height: 50},
color: '#97b488',
displayName: 'Pos1Node1',
count: 4,
},
{
rect: {left: 0, top: 0, width: 60, height: 60},
color: '#63b19e',
displayName: 'Pos1Node2',
count: 2,
},
],
]);
});
test('should handle multiple groups with same minimum count', () => {
const nodeToData = new Map([
[
{id: 1},
{
rect: {left: 0, top: 0, width: 100, height: 100},
color: '#37afa9',
displayName: 'Group1Node1',
count: 1,
},
],
[
{id: 2},
{
rect: {left: 100, top: 0, width: 100, height: 100},
color: '#37afa9',
displayName: 'Group2Node1',
count: 1,
},
],
]);
const result = groupAndSortNodes(nodeToData);
expect(result).toEqual([
[
{
rect: {left: 0, top: 0, width: 100, height: 100},
color: '#37afa9',
displayName: 'Group1Node1',
count: 1,
},
],
[
{
rect: {left: 100, top: 0, width: 100, height: 100},
color: '#37afa9',
displayName: 'Group2Node1',
count: 1,
},
],
]);
});
test('should filter out nodes without rect property', () => {
const nodeToData = new Map([
[
{id: 1},
{
rect: null,
color: '#37afa9',
displayName: 'NoRectNode',
count: 1,
},
],
[
{id: 2},
{
rect: undefined,
color: '#63b19e',
displayName: 'UndefinedRectNode',
count: 2,
},
],
[
{id: 3},
{
rect: {left: 0, top: 0, width: 100, height: 100},
color: '#80b393',
displayName: 'ValidNode',
count: 3,
},
],
]);
const result = groupAndSortNodes(nodeToData);
expect(result).toEqual([
[
{
rect: {left: 0, top: 0, width: 100, height: 100},
color: '#80b393',
displayName: 'ValidNode',
count: 3,
},
],
]);
});
});

View File

@@ -28,6 +28,7 @@ import type {
DevToolsHookSettings,
} from './types';
import type {ComponentFilter} from 'react-devtools-shared/src/frontend/types';
import type {GroupItem} from './views/TraceUpdates/canvas';
import {isReactNativeEnvironment} from './utils';
import {
sessionStorageGetItem,
@@ -142,10 +143,12 @@ export default class Agent extends EventEmitter<{
shutdown: [],
traceUpdates: [Set<HostInstance>],
drawTraceUpdates: [Array<HostInstance>],
drawGroupedTraceUpdatesWithNames: [Array<Array<GroupItem>>],
disableTraceUpdates: [],
getIfHasUnsupportedRendererVersion: [],
updateHookSettings: [$ReadOnly<DevToolsHookSettings>],
getHookSettings: [],
showNamesWhenTracing: [boolean],
}> {
_bridge: BackendBridge;
_isProfiling: boolean = false;
@@ -156,6 +159,7 @@ export default class Agent extends EventEmitter<{
_onReloadAndProfile:
| ((recordChangeDescriptions: boolean, recordTimeline: boolean) => void)
| void;
_showNamesWhenTracing: boolean = true;
constructor(
bridge: BackendBridge,
@@ -200,6 +204,7 @@ export default class Agent extends EventEmitter<{
bridge.addListener('reloadAndProfile', this.reloadAndProfile);
bridge.addListener('renamePath', this.renamePath);
bridge.addListener('setTraceUpdatesEnabled', this.setTraceUpdatesEnabled);
bridge.addListener('setShowNamesWhenTracing', this.setShowNamesWhenTracing);
bridge.addListener('startProfiling', this.startProfiling);
bridge.addListener('stopProfiling', this.stopProfiling);
bridge.addListener('storeAsGlobal', this.storeAsGlobal);
@@ -722,6 +727,7 @@ export default class Agent extends EventEmitter<{
this._traceUpdatesEnabled = traceUpdatesEnabled;
setTraceUpdatesEnabled(traceUpdatesEnabled);
this.emit('showNamesWhenTracing', this._showNamesWhenTracing);
for (const rendererID in this._rendererInterfaces) {
const renderer = ((this._rendererInterfaces[
@@ -731,6 +737,14 @@ export default class Agent extends EventEmitter<{
}
};
setShowNamesWhenTracing: (show: boolean) => void = show => {
if (this._showNamesWhenTracing === show) {
return;
}
this._showNamesWhenTracing = show;
this.emit('showNamesWhenTracing', show);
};
syncSelectionFromBuiltinElementsPanel: () => void = () => {
const target = window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0;
if (target == null) {

View File

@@ -14,8 +14,6 @@ import type Agent from '../../agent';
import {isReactNativeEnvironment} from 'react-devtools-shared/src/backend/utils';
const OUTLINE_COLOR = '#f0f0f0';
// Note these colors are in sync with DevTools Profiler chart colors.
const COLORS = [
'#37afa9',
@@ -34,11 +32,14 @@ let canvas: HTMLCanvasElement | null = null;
function drawNative(nodeToData: Map<HostInstance, Data>, agent: Agent) {
const nodesToDraw = [];
iterateNodes(nodeToData, (_, color, node) => {
iterateNodes(nodeToData, ({color, node}) => {
nodesToDraw.push({node, color});
});
agent.emit('drawTraceUpdates', nodesToDraw);
const mergedNodes = groupAndSortNodes(nodeToData);
agent.emit('drawGroupedTraceUpdatesWithNames', mergedNodes);
}
function drawWeb(nodeToData: Map<HostInstance, Data>) {
@@ -46,62 +47,142 @@ function drawWeb(nodeToData: Map<HostInstance, Data>) {
initialize();
}
const dpr = window.devicePixelRatio || 1;
const canvasFlow: HTMLCanvasElement = ((canvas: any): HTMLCanvasElement);
canvasFlow.width = window.innerWidth;
canvasFlow.height = window.innerHeight;
canvasFlow.width = window.innerWidth * dpr;
canvasFlow.height = window.innerHeight * dpr;
canvasFlow.style.width = `${window.innerWidth}px`;
canvasFlow.style.height = `${window.innerHeight}px`;
const context = canvasFlow.getContext('2d');
context.clearRect(0, 0, canvasFlow.width, canvasFlow.height);
iterateNodes(nodeToData, (rect, color) => {
if (rect !== null) {
drawBorder(context, rect, color);
}
context.scale(dpr, dpr);
context.clearRect(0, 0, canvasFlow.width / dpr, canvasFlow.height / dpr);
const mergedNodes = groupAndSortNodes(nodeToData);
mergedNodes.forEach(group => {
drawGroupBorders(context, group);
drawGroupLabel(context, group);
});
}
type GroupItem = {
rect: Rect,
color: string,
displayName: string | null,
count: number,
};
export type {GroupItem};
export function groupAndSortNodes(
nodeToData: Map<HostInstance, Data>,
): Array<Array<GroupItem>> {
const positionGroups: Map<string, Array<GroupItem>> = new Map();
iterateNodes(nodeToData, ({rect, color, displayName, count}) => {
if (!rect) return;
const key = `${rect.left},${rect.top}`;
if (!positionGroups.has(key)) positionGroups.set(key, []);
positionGroups.get(key)?.push({rect, color, displayName, count});
});
return Array.from(positionGroups.values()).sort((groupA, groupB) => {
const maxCountA = Math.max(...groupA.map(item => item.count));
const maxCountB = Math.max(...groupB.map(item => item.count));
return maxCountA - maxCountB;
});
}
function drawGroupBorders(
context: CanvasRenderingContext2D,
group: Array<GroupItem>,
) {
group.forEach(({color, rect}) => {
context.beginPath();
context.strokeStyle = color;
context.rect(rect.left, rect.top, rect.width - 1, rect.height - 1);
context.stroke();
});
}
function drawGroupLabel(
context: CanvasRenderingContext2D,
group: Array<GroupItem>,
) {
const mergedName = group
.map(({displayName, count}) =>
displayName ? `${displayName}${count > 1 ? ` x${count}` : ''}` : '',
)
.filter(Boolean)
.join(', ');
if (mergedName) {
drawLabel(context, group[0].rect, mergedName, group[0].color);
}
}
export function draw(nodeToData: Map<HostInstance, Data>, agent: Agent): void {
return isReactNativeEnvironment()
? drawNative(nodeToData, agent)
: drawWeb(nodeToData);
}
type DataWithColorAndNode = {
...Data,
color: string,
node: HostInstance,
};
function iterateNodes(
nodeToData: Map<HostInstance, Data>,
execute: (rect: Rect | null, color: string, node: HostInstance) => void,
execute: (data: DataWithColorAndNode) => void,
) {
nodeToData.forEach(({count, rect}, node) => {
const colorIndex = Math.min(COLORS.length - 1, count - 1);
nodeToData.forEach((data, node) => {
const colorIndex = Math.min(COLORS.length - 1, data.count - 1);
const color = COLORS[colorIndex];
execute(rect, color, node);
execute({
color,
node,
count: data.count,
displayName: data.displayName,
expirationTime: data.expirationTime,
lastMeasuredAt: data.lastMeasuredAt,
rect: data.rect,
});
});
}
function drawBorder(
function drawLabel(
context: CanvasRenderingContext2D,
rect: Rect,
text: string,
color: string,
): void {
const {height, left, top, width} = rect;
const {left, top} = rect;
context.font = '10px monospace';
context.textBaseline = 'middle';
context.textAlign = 'center';
// outline
context.lineWidth = 1;
context.strokeStyle = OUTLINE_COLOR;
const padding = 2;
const textHeight = 14;
context.strokeRect(left - 1, top - 1, width + 2, height + 2);
const metrics = context.measureText(text);
const backgroundWidth = metrics.width + padding * 2;
const backgroundHeight = textHeight;
const labelX = left;
const labelY = top - backgroundHeight;
// inset
context.lineWidth = 1;
context.strokeStyle = OUTLINE_COLOR;
context.strokeRect(left + 1, top + 1, width - 1, height - 1);
context.strokeStyle = color;
context.fillStyle = color;
context.fillRect(labelX, labelY, backgroundWidth, backgroundHeight);
context.setLineDash([0]);
// border
context.lineWidth = 1;
context.strokeRect(left, top, width - 1, height - 1);
context.setLineDash([0]);
context.fillStyle = '#000000';
context.fillText(
text,
labelX + backgroundWidth / 2,
labelY + backgroundHeight / 2,
);
}
function destroyNative(agent: Agent) {

View File

@@ -9,7 +9,7 @@
import Agent from 'react-devtools-shared/src/backend/agent';
import {destroy as destroyCanvas, draw} from './canvas';
import {getNestedBoundingClientRect} from '../utils';
import {extractHOCNames, getNestedBoundingClientRect} from '../utils';
import type {HostInstance} from '../../types';
import type {Rect} from '../utils';
@@ -24,6 +24,12 @@ const MAX_DISPLAY_DURATION = 3000;
// How long should a rect be considered valid for?
const REMEASUREMENT_AFTER_DURATION = 250;
// Markers for different types of HOCs
const HOC_MARKERS = new Map([
['Forget', '✨'],
['Memo', '🧠'],
]);
// Some environments (e.g. React Native / Hermes) don't support the performance API yet.
const getCurrentTime =
// $FlowFixMe[method-unbinding]
@@ -36,6 +42,7 @@ export type Data = {
expirationTime: number,
lastMeasuredAt: number,
rect: Rect | null,
displayName: string | null,
};
const nodeToData: Map<HostInstance, Data> = new Map();
@@ -43,11 +50,20 @@ const nodeToData: Map<HostInstance, Data> = new Map();
let agent: Agent = ((null: any): Agent);
let drawAnimationFrameID: AnimationFrameID | null = null;
let isEnabled: boolean = false;
let showNames: boolean = false;
let redrawTimeoutID: TimeoutID | null = null;
export function initialize(injectedAgent: Agent): void {
agent = injectedAgent;
agent.addListener('traceUpdates', traceUpdates);
agent.addListener('showNamesWhenTracing', (shouldShowNames: boolean) => {
showNames = shouldShowNames;
if (isEnabled) {
if (drawAnimationFrameID === null) {
drawAnimationFrameID = requestAnimationFrame(prepareToDraw);
}
}
});
}
export function toggleEnabled(value: boolean): void {
@@ -71,9 +87,7 @@ export function toggleEnabled(value: boolean): void {
}
function traceUpdates(nodes: Set<HostInstance>): void {
if (!isEnabled) {
return;
}
if (!isEnabled) return;
nodes.forEach(node => {
const data = nodeToData.get(node);
@@ -81,11 +95,27 @@ function traceUpdates(nodes: Set<HostInstance>): void {
let lastMeasuredAt = data != null ? data.lastMeasuredAt : 0;
let rect = data != null ? data.rect : null;
if (rect === null || lastMeasuredAt + REMEASUREMENT_AFTER_DURATION < now) {
lastMeasuredAt = now;
rect = measureNode(node);
}
let displayName = showNames
? agent.getComponentNameForHostInstance(node)
: null;
if (displayName) {
const {baseComponentName, hocNames} = extractHOCNames(displayName);
const markers = hocNames.map(hoc => HOC_MARKERS.get(hoc) || '').join('');
const enhancedDisplayName = markers
? `${markers}${baseComponentName}`
: baseComponentName;
displayName = enhancedDisplayName;
}
nodeToData.set(node, {
count: data != null ? data.count + 1 : 1,
expirationTime:
@@ -97,6 +127,7 @@ function traceUpdates(nodes: Set<HostInstance>): void {
: now + DISPLAY_DURATION,
lastMeasuredAt,
rect,
displayName: showNames ? displayName : null,
});
});

View File

@@ -138,3 +138,28 @@ export function getElementDimensions(domElement: HTMLElement): {
paddingBottom: parseInt(calculatedStyle.paddingBottom, 10),
};
}
export function extractHOCNames(displayName: string): {
baseComponentName: string,
hocNames: string[],
} {
if (!displayName) return {baseComponentName: '', hocNames: []};
const hocRegex = /([A-Z][a-zA-Z0-9]*?)\((.*)\)/g;
const hocNames: string[] = [];
let baseComponentName = displayName;
let match;
while ((match = hocRegex.exec(baseComponentName)) != null) {
if (Array.isArray(match)) {
const [, hocName, inner] = match;
hocNames.push(hocName);
baseComponentName = inner;
}
}
return {
baseComponentName,
hocNames,
};
}

View File

@@ -234,6 +234,7 @@ type FrontendEvents = {
renamePath: [RenamePath],
savedPreferences: [SavedPreferencesParams],
setTraceUpdatesEnabled: [boolean],
setShowNamesWhenTracing: [boolean],
shutdown: [],
startInspectingHost: [],
startProfiling: [StartProfilingParams],

View File

@@ -50,6 +50,8 @@ export const LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY =
'React::DevTools::traceUpdatesEnabled';
export const LOCAL_STORAGE_SUPPORTS_PROFILING_KEY =
'React::DevTools::supportsProfiling';
export const LOCAL_STORAGE_SHOW_NAMES_WHEN_TRACING_KEY =
'React::DevTools::showNamesWhenTracing';
export const PROFILER_EXPORT_VERSION = 5;

View File

@@ -34,8 +34,10 @@ export default function GeneralSettings(_: {}): React.Node {
setDisplayDensity,
setTheme,
setTraceUpdatesEnabled,
setShowNamesWhenTracing,
theme,
traceUpdatesEnabled,
showNamesWhenTracing,
} = useContext(SettingsContext);
const {backendVersion, supportsTraceUpdates} = useContext(StoreContext);
@@ -83,6 +85,19 @@ export default function GeneralSettings(_: {}): React.Node {
/>{' '}
Highlight updates when components render.
</label>
<div className={styles.Setting}>
<label>
<input
type="checkbox"
checked={showNamesWhenTracing}
disabled={!traceUpdatesEnabled}
onChange={({currentTarget}) =>
setShowNamesWhenTracing(currentTarget.checked)
}
/>{' '}
Show component names while highlighting.
</label>
</div>
</div>
)}

View File

@@ -21,6 +21,7 @@ import {
LOCAL_STORAGE_BROWSER_THEME,
LOCAL_STORAGE_PARSE_HOOK_NAMES_KEY,
LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY,
LOCAL_STORAGE_SHOW_NAMES_WHEN_TRACING_KEY,
} from 'react-devtools-shared/src/constants';
import {
COMFORTABLE_LINE_HEIGHT,
@@ -53,6 +54,9 @@ type Context = {
traceUpdatesEnabled: boolean,
setTraceUpdatesEnabled: (value: boolean) => void,
showNamesWhenTracing: boolean,
setShowNamesWhenTracing: (showNames: boolean) => void,
};
const SettingsContext: ReactContext<Context> = createContext<Context>(
@@ -111,6 +115,11 @@ function SettingsContextController({
LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY,
false,
);
const [showNamesWhenTracing, setShowNamesWhenTracing] =
useLocalStorageWithLog<boolean>(
LOCAL_STORAGE_SHOW_NAMES_WHEN_TRACING_KEY,
true,
);
const documentElements = useMemo<DocumentElements>(() => {
const array: Array<HTMLElement> = [
@@ -164,6 +173,10 @@ function SettingsContextController({
bridge.send('setTraceUpdatesEnabled', traceUpdatesEnabled);
}, [bridge, traceUpdatesEnabled]);
useEffect(() => {
bridge.send('setShowNamesWhenTracing', showNamesWhenTracing);
}, [bridge, showNamesWhenTracing]);
const value: Context = useMemo(
() => ({
displayDensity,
@@ -179,6 +192,8 @@ function SettingsContextController({
theme,
browserTheme,
traceUpdatesEnabled,
showNamesWhenTracing,
setShowNamesWhenTracing,
}),
[
displayDensity,
@@ -190,6 +205,7 @@ function SettingsContextController({
theme,
browserTheme,
traceUpdatesEnabled,
showNamesWhenTracing,
],
);

View File

@@ -154,3 +154,14 @@
padding: 0;
margin: 0;
}
.Setting .Setting {
margin-left: 1rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.Setting label:has(input:disabled) {
opacity: 0.5;
cursor: default;
}