From 2fa25fbeeb094d6fbf188beeea82ad5436fa6eff Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Mon, 22 Jun 2026 17:42:57 +0200 Subject: [PATCH] feat(devtools): Add highlighting based visualization of the CD This is based on the exisiting profiler and uses the existing highlighting feature --- .../src/lib/client-event-subscribers.spec.ts | 8 ++ .../src/lib/client-event-subscribers.ts | 53 ++++++++---- .../component-inspector.ts | 16 ++++ .../src/lib/highlighter.ts | 81 ++++++++++++++----- .../directive-explorer.component.ts | 12 ++- .../profiler/profiler.component.html | 3 +- .../profiler/profiler.component.ts | 46 ++++++++++- .../projects/protocol/src/lib/messages.ts | 2 + 8 files changed, 184 insertions(+), 37 deletions(-) diff --git a/devtools/projects/ng-devtools-backend/src/lib/client-event-subscribers.spec.ts b/devtools/projects/ng-devtools-backend/src/lib/client-event-subscribers.spec.ts index 132c1a63abef..983badb604e0 100644 --- a/devtools/projects/ng-devtools-backend/src/lib/client-event-subscribers.spec.ts +++ b/devtools/projects/ng-devtools-backend/src/lib/client-event-subscribers.spec.ts @@ -49,6 +49,14 @@ describe('ClientEventSubscriber', () => { expect(messageBusMock.on).toHaveBeenCalledWith('inspectorEnd', jasmine.any(Function)); expect(messageBusMock.on).toHaveBeenCalledWith('createHighlightOverlay', jasmine.any(Function)); expect(messageBusMock.on).toHaveBeenCalledWith('removeHighlightOverlay', jasmine.any(Function)); + expect(messageBusMock.on).toHaveBeenCalledWith( + 'createRenderScanOverlay', + jasmine.any(Function), + ); + expect(messageBusMock.on).toHaveBeenCalledWith( + 'removeRenderScanOverlay', + jasmine.any(Function), + ); expect(messageBusMock.on).toHaveBeenCalledWith('createHydrationOverlay', jasmine.any(Function)); expect(messageBusMock.on).toHaveBeenCalledWith('removeHydrationOverlay', jasmine.any(Function)); }); diff --git a/devtools/projects/ng-devtools-backend/src/lib/client-event-subscribers.ts b/devtools/projects/ng-devtools-backend/src/lib/client-event-subscribers.ts index 20ac9360e2c3..fd9de9133a55 100644 --- a/devtools/projects/ng-devtools-backend/src/lib/client-event-subscribers.ts +++ b/devtools/projects/ng-devtools-backend/src/lib/client-event-subscribers.ts @@ -73,6 +73,7 @@ export const subscribeToClientEvents = ( }, ): void => { const inspector: InspectorRef = {ref: null}; + const profilerState = {isProfiling: false}; messageBus.on('shutdown', shutdownCallback(messageBus)); @@ -83,10 +84,12 @@ export const subscribeToClientEvents = ( messageBus.on('queryNgAvailability', checkForAngularCallback(messageBus)); - messageBus.on('startProfiling', startProfilingCallback(messageBus)); - messageBus.on('stopProfiling', stopProfilingCallback(messageBus)); + messageBus.on('startProfiling', startProfilingCallback(messageBus, profilerState)); + messageBus.on('stopProfiling', stopProfilingCallback(messageBus, profilerState)); - messageBus.on('setSelectedComponent', selectedComponentCallback(inspector)); + messageBus.on('setSelectedComponent', selectedComponentCallback(inspector, profilerState)); + messageBus.on('createRenderScanOverlay', renderScanOverlayCallback(inspector)); + messageBus.on('removeRenderScanOverlay', removeRenderScanOverlayCallback()); messageBus.on('getNestedProperties', getNestedPropertiesCallback(messageBus)); messageBus.on('getRoutes', getRoutesCallback(messageBus)); @@ -202,22 +205,40 @@ export const viewSourceFromRouter = (constructName: string, type: RoutePropertyT return getRouterCallableConstructRef(router.config, type, constructName); }; -const startProfilingCallback = (messageBus: MessageBus) => () => - startProfiling((frame: ProfilerFrame) => { - messageBus.emit('sendProfilerChunk', [frame]); - }); +const startProfilingCallback = + (messageBus: MessageBus, profilerState: {isProfiling: boolean}) => () => { + profilerState.isProfiling = true; + startProfiling((frame: ProfilerFrame) => { + messageBus.emit('sendProfilerChunk', [frame]); + }); + }; + +const stopProfilingCallback = + (messageBus: MessageBus, profilerState: {isProfiling: boolean}) => () => { + profilerState.isProfiling = false; + messageBus.emit('profilerResults', [stopProfiling()]); + }; + +const selectedComponentCallback = + (inspector: InspectorRef, profilerState: {isProfiling: boolean}) => + (position: ElementPosition) => { + if (profilerState.isProfiling) { + return; + } + const node = queryDirectiveForest( + position, + initializeOrGetDirectiveForestHooks().getIndexedDirectiveForest(), + ); + setConsoleReference({node, position}); + inspector.ref?.highlightByPosition(position); + }; -const stopProfilingCallback = (messageBus: MessageBus) => () => { - messageBus.emit('profilerResults', [stopProfiling()]); +const renderScanOverlayCallback = (inspector: InspectorRef) => (positions: ElementPosition[]) => { + inspector.ref?.highlightByPositions(positions); }; -const selectedComponentCallback = (inspector: InspectorRef) => (position: ElementPosition) => { - const node = queryDirectiveForest( - position, - initializeOrGetDirectiveForestHooks().getIndexedDirectiveForest(), - ); - setConsoleReference({node, position}); - inspector.ref?.highlightByPosition(position); +const removeRenderScanOverlayCallback = () => () => { + unHighlight(); }; const getNestedPropertiesCallback = diff --git a/devtools/projects/ng-devtools-backend/src/lib/component-inspector/component-inspector.ts b/devtools/projects/ng-devtools-backend/src/lib/component-inspector/component-inspector.ts index 4412d5cc67f6..aefbb327b3dc 100644 --- a/devtools/projects/ng-devtools-backend/src/lib/component-inspector/component-inspector.ts +++ b/devtools/projects/ng-devtools-backend/src/lib/component-inspector/component-inspector.ts @@ -13,8 +13,10 @@ import { findComponentAndHost, highlightHydrationElement, highlightSelectedElement, + highlightSelectedElementsForProfiler, removeHydrationHighlights, unHighlight, + unHighlightProfiler, } from '../highlighter'; import {initializeOrGetDirectiveForestHooks} from '../hooks'; import {ComponentTreeNode} from '../interfaces'; @@ -109,6 +111,20 @@ export class ComponentInspector { } } + highlightByPositions(positions: ElementPosition[]): void { + const forest: ComponentTreeNode[] = initializeOrGetDirectiveForestHooks().getDirectiveForest(); + const elementsToHighlight = positions + .map((position) => findNodeInForest(position, forest)) + .filter((element): element is HTMLElement => element !== null); + + if (elementsToHighlight.length > 0) { + highlightSelectedElementsForProfiler(elementsToHighlight); + return; + } + + unHighlightProfiler(); + } + highlightHydrationNodes(): void { const forest: ComponentTreeNode[] = initializeOrGetDirectiveForestHooks().getDirectiveForest(); diff --git a/devtools/projects/ng-devtools-backend/src/lib/highlighter.ts b/devtools/projects/ng-devtools-backend/src/lib/highlighter.ts index 3aea68e940a8..95cb9723ac8d 100644 --- a/devtools/projects/ng-devtools-backend/src/lib/highlighter.ts +++ b/devtools/projects/ng-devtools-backend/src/lib/highlighter.ts @@ -11,10 +11,14 @@ import {HydrationStatus} from '../../../protocol'; import {ngDebugClient} from './ng-debug-api/ng-debug-api'; let hydrationOverlayItems: HTMLElement[] = []; -let selectedElementOverlay: HTMLElement | null = null; +let profilerOverlays: HTMLElement[] = []; +let selectedElementOverlays: HTMLElement[] = []; let selectedElement: Node | null = null; const DEV_TOOLS_HIGHLIGHT_NODE_ID = '____ngDevToolsHighlight'; +const PROFILER_OVERLAY_COLOR: RgbColor = [115, 97, 230]; +const DEFAULT_OVERLAY_FILL_ALPHA = 0.35; +const PROFILER_OVERLAY_FILL_ALPHA = 0.1; const OVERLAY_CONTENT_MARGIN = 4; const MINIMAL_OVERLAY_CONTENT_SIZE = { @@ -35,10 +39,18 @@ const HYDRATION_SVG = ` const HYDRATION_SKIPPED_SVG = ``; const HYDRATION_ERROR_SVG = ``; -function createOverlay(color: RgbColor): {overlay: HTMLElement; overlayContent: HTMLElement} { +function createOverlay( + color: RgbColor, + fillAlpha: number = DEFAULT_OVERLAY_FILL_ALPHA, + showBorder: boolean = false, +): {overlay: HTMLElement; overlayContent: HTMLElement} { const overlay = document.createElement('div'); overlay.className = 'ng-devtools-overlay'; - overlay.style.backgroundColor = toCSSColor(...color, 0.35); + overlay.style.backgroundColor = toCSSColor(...color, fillAlpha); + if (showBorder) { + overlay.style.border = `1px solid ${toCSSColor(...color)}`; + overlay.style.boxSizing = 'border-box'; + } overlay.style.position = 'absolute'; overlay.style.zIndex = '2147483647'; overlay.style.pointerEvents = 'none'; @@ -87,9 +99,40 @@ export function highlightSelectedElement(el: Node): void { if (el === selectedElement) { return; } - unHighlight(); - selectedElementOverlay = addHighlightForElement(el); - selectedElement = el; + highlightSelectedElements([el]); +} + +export function highlightSelectedElementsForProfiler(elements: Node[]): void { + unHighlightProfiler(); + profilerOverlays = elements + .map((element) => + addHighlightForElement( + element, + PROFILER_OVERLAY_COLOR, + undefined, + PROFILER_OVERLAY_FILL_ALPHA, + true, + true, + ), + ) + .filter((overlay): overlay is HTMLElement => overlay !== null); +} + +export function unHighlightProfiler(): void { + profilerOverlays.forEach((overlay) => { + if (inDoc(overlay)) { + document.body.removeChild(overlay); + } + }); + profilerOverlays = []; +} + +export function highlightSelectedElements(elements: Node[]): void { + clearSelectedElementHighlights(); + selectedElement = null; + selectedElementOverlays = elements + .map((element) => addHighlightForElement(element)) + .filter((overlay): overlay is HTMLElement => overlay !== null); } export function highlightHydrationElement(el: Node, status: HydrationStatus) { @@ -108,19 +151,18 @@ export function highlightHydrationElement(el: Node, status: HydrationStatus) { } export function unHighlight(): void { - if (!selectedElementOverlay) { - return; - } - - for (const node of document.body.childNodes) { - if (node === selectedElementOverlay) { - document.body.removeChild(selectedElementOverlay); + clearSelectedElementHighlights(); +} - break; +function clearSelectedElementHighlights(): void { + selectedElementOverlays.forEach((overlay) => { + if (inDoc(overlay)) { + document.body.removeChild(overlay); } - } + }); - selectedElementOverlay = null; + selectedElementOverlays = []; + selectedElement = null; } export function removeHydrationHighlights(): void { @@ -145,6 +187,9 @@ function addHighlightForElement( el: Node, color: RgbColor = COLORS.blue, overlayType?: NonNullable['status'], + fillAlpha: number = DEFAULT_OVERLAY_FILL_ALPHA, + showLabel: boolean = true, + showBorder: boolean = false, ): HTMLElement | null { const cmp = findComponentAndHost(el).component; const rect = getComponentRect(el); @@ -153,7 +198,7 @@ function addHighlightForElement( return null; } - const {overlay, overlayContent} = createOverlay(color); + const {overlay, overlayContent} = createOverlay(color, fillAlpha, showBorder); if (!rect) return null; const content: Node[] = []; @@ -169,7 +214,7 @@ function addHighlightForElement( const svg = createOverlaySvgElement(overlayType!); content.push(svg); } - } else if (componentName) { + } else if (showLabel && componentName) { const middleText = document.createTextNode(componentName); const pre = document.createElement('span'); pre.innerText = `<`; diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.component.ts b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.component.ts index 9500d86bf68a..968c1ee71ac8 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.component.ts +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.component.ts @@ -187,11 +187,21 @@ export class DirectiveExplorerComponent { handleNodeSelection(node: IndexedNode | null): void { if (node) { + const isSameNode = this._clickedElement && sameDirectives(this._clickedElement, node); + const isSamePosition = + this._clickedElement && String(this._clickedElement.position) === String(node.position); + + if (isSameNode && isSamePosition) { + this._clickedElement = node; + this.currentSelectedElement.set(node); + return; + } + // We want to guarantee that we're not reusing any of the previous properties. // That's possible if the user has selected an NgForOf and after that // they select another NgForOf instance. In this case, we don't want to diff the props // we want to render from scratch. - if (this._clickedElement && !sameDirectives(this._clickedElement, node)) { + if (this._clickedElement && !isSameNode) { this._propResolver.clearProperties(); } this._clickedElement = node; diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/profiler/profiler.component.html b/devtools/projects/ng-devtools/src/lib/devtools-tabs/profiler/profiler.component.html index 93f8f73a57db..b1414c476aaa 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/profiler/profiler.component.html +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/profiler/profiler.component.html @@ -39,7 +39,8 @@

- Interact to preview change detection. Clicking stop ends this Profiler recording. + Interact to preview change detection. The inspected app will be highlighted live while + recording. Clicking stop ends this Profiler recording.

diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/profiler/profiler.component.ts b/devtools/projects/ng-devtools/src/lib/devtools-tabs/profiler/profiler.component.ts index 0edbfe65f042..eb1f58435b67 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/profiler/profiler.component.ts +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/profiler/profiler.component.ts @@ -11,7 +11,7 @@ import {MatDialog} from '@angular/material/dialog'; import {MatIcon} from '@angular/material/icon'; import {MatTooltip} from '@angular/material/tooltip'; import {MatProgressBar} from '@angular/material/progress-bar'; -import {Events, MessageBus, ProfilerFrame} from '../../../../../protocol'; +import {ElementPosition, Events, MessageBus, ProfilerFrame} from '../../../../../protocol'; import {Subject} from 'rxjs'; import {FileApiService} from './file-api-service'; @@ -94,10 +94,12 @@ export class ProfilerComponent { this._messageBus.on('sendProfilerChunk', (chunkOfRecords: ProfilerFrame) => { this.stream.next([chunkOfRecords]); this._buffer.push(chunkOfRecords); + this._highlightRenderScanOverlay(chunkOfRecords); }); } startRecording(): void { + this._messageBus.emit('removeRenderScanOverlay'); this.state.set('recording'); this._messageBus.emit('startProfiling'); } @@ -105,6 +107,7 @@ export class ProfilerComponent { stopRecording(): void { this.state.set('visualizing'); this._messageBus.emit('stopProfiling'); + this._messageBus.emit('removeRenderScanOverlay'); this.stream.complete(); } @@ -121,8 +124,49 @@ export class ProfilerComponent { } discardRecording(): void { + this._messageBus.emit('removeRenderScanOverlay'); this.stream = new Subject(); this.state.set('idle'); this._buffer = []; } + + private _highlightRenderScanOverlay(frame: ProfilerFrame): void { + const positions = collectRenderScanPositions(frame.directives); + if (!positions.length) { + this._messageBus.emit('removeRenderScanOverlay'); + return; + } + + this._messageBus.emit('createRenderScanOverlay', [positions]); + } +} + +function collectRenderScanPositions( + elements: ProfilerFrame['directives'], + path: ElementPosition = [], + positions: ElementPosition[] = [], +): ElementPosition[] { + elements.forEach((element, index) => { + if (!element) { + return; + } + + const position = [...path, index]; + if (didRunChangeDetection(element)) { + positions.push(position); + } + + collectRenderScanPositions(element.children, position, positions); + }); + + return positions; +} + +function didRunChangeDetection(profile: ProfilerFrame['directives'][number]): boolean { + const components = profile.directives.filter((directive) => directive.isComponent); + if (!components.length) { + return false; + } + + return components.some((component) => component.changeDetection !== undefined); } diff --git a/devtools/projects/protocol/src/lib/messages.ts b/devtools/projects/protocol/src/lib/messages.ts index 02447f4e6de5..5625cd4ea0aa 100644 --- a/devtools/projects/protocol/src/lib/messages.ts +++ b/devtools/projects/protocol/src/lib/messages.ts @@ -438,6 +438,8 @@ export interface Events { highlightComponent: (id: number) => void; selectComponent: (id: number) => void; removeComponentHighlight: () => void; + createRenderScanOverlay: (positions: ElementPosition[]) => void; + removeRenderScanOverlay: () => void; enableTimingAPI: () => void; disableTimingAPI: () => void;