diff --git a/packages/react-devtools-shared/src/__tests__/traceUpdates-test.js b/packages/react-devtools-shared/src/__tests__/traceUpdates-test.js new file mode 100644 index 0000000000000..b8aa978a1499e --- /dev/null +++ b/packages/react-devtools-shared/src/__tests__/traceUpdates-test.js @@ -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, + }, + ], + ]); + }); +}); diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js index e05abf51aba46..111c458bdbda1 100644 --- a/packages/react-devtools-shared/src/backend/agent.js +++ b/packages/react-devtools-shared/src/backend/agent.js @@ -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], drawTraceUpdates: [Array], + drawGroupedTraceUpdatesWithNames: [Array>], disableTraceUpdates: [], getIfHasUnsupportedRendererVersion: [], updateHookSettings: [$ReadOnly], 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) { diff --git a/packages/react-devtools-shared/src/backend/views/TraceUpdates/canvas.js b/packages/react-devtools-shared/src/backend/views/TraceUpdates/canvas.js index c44751559431c..3e01397546721 100644 --- a/packages/react-devtools-shared/src/backend/views/TraceUpdates/canvas.js +++ b/packages/react-devtools-shared/src/backend/views/TraceUpdates/canvas.js @@ -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, 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) { @@ -46,62 +47,142 @@ function drawWeb(nodeToData: Map) { 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, +): Array> { + const positionGroups: Map> = 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, +) { + 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, +) { + 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, agent: Agent): void { return isReactNativeEnvironment() ? drawNative(nodeToData, agent) : drawWeb(nodeToData); } +type DataWithColorAndNode = { + ...Data, + color: string, + node: HostInstance, +}; + function iterateNodes( nodeToData: Map, - 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; - - // outline - context.lineWidth = 1; - context.strokeStyle = OUTLINE_COLOR; - - context.strokeRect(left - 1, top - 1, width + 2, height + 2); - - // inset - context.lineWidth = 1; - context.strokeStyle = OUTLINE_COLOR; - context.strokeRect(left + 1, top + 1, width - 1, height - 1); - context.strokeStyle = color; - - context.setLineDash([0]); - - // border - context.lineWidth = 1; - context.strokeRect(left, top, width - 1, height - 1); - - context.setLineDash([0]); + const {left, top} = rect; + context.font = '10px monospace'; + context.textBaseline = 'middle'; + context.textAlign = 'center'; + + const padding = 2; + const textHeight = 14; + + const metrics = context.measureText(text); + const backgroundWidth = metrics.width + padding * 2; + const backgroundHeight = textHeight; + const labelX = left; + const labelY = top - backgroundHeight; + + context.fillStyle = color; + context.fillRect(labelX, labelY, backgroundWidth, backgroundHeight); + + context.fillStyle = '#000000'; + context.fillText( + text, + labelX + backgroundWidth / 2, + labelY + backgroundHeight / 2, + ); } function destroyNative(agent: Agent) { diff --git a/packages/react-devtools-shared/src/backend/views/TraceUpdates/index.js b/packages/react-devtools-shared/src/backend/views/TraceUpdates/index.js index 2f27a941b5622..48e504eddbb1d 100644 --- a/packages/react-devtools-shared/src/backend/views/TraceUpdates/index.js +++ b/packages/react-devtools-shared/src/backend/views/TraceUpdates/index.js @@ -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 = new Map(); @@ -43,11 +50,20 @@ const nodeToData: Map = 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): void { - if (!isEnabled) { - return; - } + if (!isEnabled) return; nodes.forEach(node => { const data = nodeToData.get(node); @@ -81,11 +95,27 @@ function traceUpdates(nodes: Set): 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): void { : now + DISPLAY_DURATION, lastMeasuredAt, rect, + displayName: showNames ? displayName : null, }); }); diff --git a/packages/react-devtools-shared/src/backend/views/utils.js b/packages/react-devtools-shared/src/backend/views/utils.js index 009bb60ffa5da..595b87c481874 100644 --- a/packages/react-devtools-shared/src/backend/views/utils.js +++ b/packages/react-devtools-shared/src/backend/views/utils.js @@ -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, + }; +} diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index cb494e1b3c1ba..e00ba5518a1a9 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -234,6 +234,7 @@ type FrontendEvents = { renamePath: [RenamePath], savedPreferences: [SavedPreferencesParams], setTraceUpdatesEnabled: [boolean], + setShowNamesWhenTracing: [boolean], shutdown: [], startInspectingHost: [], startProfiling: [StartProfilingParams], diff --git a/packages/react-devtools-shared/src/constants.js b/packages/react-devtools-shared/src/constants.js index b08738165906c..bb8d9f5b130f9 100644 --- a/packages/react-devtools-shared/src/constants.js +++ b/packages/react-devtools-shared/src/constants.js @@ -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; diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/GeneralSettings.js b/packages/react-devtools-shared/src/devtools/views/Settings/GeneralSettings.js index d56f32f81142c..69f4ec737581a 100644 --- a/packages/react-devtools-shared/src/devtools/views/Settings/GeneralSettings.js +++ b/packages/react-devtools-shared/src/devtools/views/Settings/GeneralSettings.js @@ -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. +
+ +
)} diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js index 196ea806f6aac..ec7b3ba9c9da4 100644 --- a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js @@ -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 = createContext( @@ -111,6 +115,11 @@ function SettingsContextController({ LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY, false, ); + const [showNamesWhenTracing, setShowNamesWhenTracing] = + useLocalStorageWithLog( + LOCAL_STORAGE_SHOW_NAMES_WHEN_TRACING_KEY, + true, + ); const documentElements = useMemo(() => { const array: Array = [ @@ -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, ], ); diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsShared.css b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsShared.css index c322bc8d0eaab..ce27e83a3fa81 100644 --- a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsShared.css +++ b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsShared.css @@ -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; +}