20 May 2024
5 min

Debugging techniques – Angular DevTools

Angular DevTools

Angular DevTools is a browser extension that provides debugging and profiling capabilities for Angular applications.
There’s a good coverage of its capabilities in the official docs.
Here I want to briefly describe the architecture of this tool and outline utilities it uses to build a components
tree and the change detection profiler.

The “Components” tab lets you inspect a tree of components for a current application page:

Image alt

Interestingly, though not surprisingly, the DevTools extension uses Angular to build the UI you see in the Chrome console.
You can think of this user facing part of the extension as a frontend equivalent of a browser application.
For example, those two tabs:

Image alt

are rendered by the
DevToolsTabs
component. The component is part of the ng-devtools module in the sources:

Image alt

As you can see, there’s also ng-devtools-backend implementation that’s responsible for interaction with your Angular application.
Its primary job is to build a components tree that is then rendered by the frontend part of the DevTools extension.
But it does lots of other things too, e.g.
highlighting
a node in your inspected Angular application by applying styles to a node:

Image alt

Frontend and backend parts of the DevTools extension communicate through a
message bus:

export abstract class MessageBus<T> {
  abstract on<E extends keyof T>(topic: E, cb: T[E]): void;
  abstract once<E extends keyof T>(topic: E, cb: T[E]): void;
  abstract emit<E extends keyof T>(topic: E, args?: Parameters<T[E]>): boolean;
  abstract destroy(): void;
}

Here’s, for example,
how
Angular subscribes to the createHighlightOverlay event that initiates the highlighting process:

const setupInspector = (messageBus: MessageBus<Events>) => {
  const inspector = new ComponentInspector({...});

  messageBus.on('createHighlightOverlay', (position: ElementPosition) => {
    inspector.highlightByPosition(position);
  });

Using discovery utils

To build a tree of components and attach corresponding information it uses the same set of discovery
utils that we explored in the global utils section.
Those utils are employed by the
RStrategy,
which is the primary way for Angular to build to inspect the components tree.
This strategy is used by default if the discovery utils are exposed globally:

export class RTreeStrategy {
  supports(_: any): boolean {
    return ['getDirectiveMetadata', 'getComponent', 'getDirectives'].every(
        (method) => typeof (window as any).ng[method] === 'function');
  }

  build(element: Element): ComponentTreeNode[] {
    // We want to start from the root element so that we can find components which are attached to
    // the application ref and which host elements have been inserted with DOM APIs.
    while (element.parentElement) {
      element = element.parentElement;
    }
    const getComponent = (window as any).ng.getComponent as (element: Element) => {};
    const getDirectives = (window as any).ng.getDirectives as (node: Node) => {}[];
    const result = extractViewTree(element, [], getComponent, getDirectives);
    return result;
  }
}

This means that information presented here:

Image alt

Could be extracted and inspected in the Chrome console using ng.getComponent utility:

Image alt

If you’re curious to see how this process happens inside the build method of the strategy,
you could even put a breakpoint into the source code of the extension:

Image alt

On the other hand, when Angular runs in the production mode the framework doesn't expose discovery utils through the global ng namespace.
For this reason inspecting the application that runs in the production mode isn't supported yet.
You will see the following when trying to run the DevTools:

Image alt

There’s the
LTreeStrategy
in the works that possibly could enable that scenario. The strategy relies on the custom
ngContext
DOM element property to retrieve the corresponding LView and TNode.
The strategy uses these nodes to retrieve the meta information about a component or directives:

export class LTreeStrategy {
  supports(element: Element): boolean {
    return typeof (element as any).__ngContext__ !== 'undefined';
  }

  private _getNode(lView: any, data: any, idx: number): ComponentTreeNode {
    const directives: DirectiveInstanceType[] = [];
    let component: ComponentInstanceType|null = null;

    const tNode = data[idx];
    const node = lView[idx][ELEMENT];

    for (let i = tNode.directiveStart; i < tNode.directiveEnd; i++) {
      const instance = lView[i];
      const dirMeta = data[i];
      if (dirMeta && dirMeta.template) {
        component = { ... };
      } else if (dirMeta) {
        directives.push({... });
      }
    }
  }
}

The custom __ngContext__ property at this point is available even in the production environment.
To retrieve the LView, Angular records the id of the corresponding LView
in the __ngContext__ property of each component's host element:

Image alt

Then it retrieves it using getLViewById from the
TRACKED_LVIEWS
storage:

/** Starts tracking an LView. */
export function registerLView(lView: LView): void {
  ngDevMode &&
    assertNumber(lView[ID], 'LView must have an ID in order to be registered');
  TRACKED_LVIEWS.set(lView[ID], lView);
}

/** Gets an LView by its unique ID. */
export function getLViewById(id: number): LView | null {
  ngDevMode && assertNumber(id, 'ID used for LView lookup must be a number');
  return TRACKED_LVIEWS.get(id) || null;
}

By doing this, Angular supports the LView retrieval in runtime even in the production mode.
And that’s exactly what LTreeStrategy relies on.

Profiling implementation

The "Profiler" tab lets you inspect the process of Angular's change detection in great detail.
You click the “Start recording” button and Angular DevTools starts capturing change detection execution events,
such as template update or lifecycle hook execution.

Image alt

Once the change detection process recording is stopped, it’s rendered like this:

Image alt

Backend of the DevTools extension is responsible for capturing those events and creating frames. To do that, the profiler
uses
a strategy called
NgProfiler.
This strategy registers a callback through the same
setProfiler
function that we explored in previous section.

When a particular change detection event arrives to the callback, the corresponding
hook
is triggered:

const getHooks = (onFrame) => {
  const timeStartMap: Record<string, number> = {};
  return {
    onCreate,
    onChangeDetectionStart,
    onChangeDetectionEnd,
    onDestroy,
    onLifecycleHookStart,
    onLifecycleHookEnd,
    onOutputStart,
    onOutputEnd,
  };
};

As you can see by the hooks structure, most events have a start and an end equivalent.
Inside the hook, when a start equivalent of a change detection event is received, Angular records the time in the
timeStartMap.
When the end equivalent of an event is received, it
calculates the duration
of the time between the start and the end part an event.

Here how it's done for the onOutput[Start/End] event:

const getHooks = (onFrame) => {
	const timeStartMap: Record<string, number> = {};
	return {
		...,
		onOutputStart(componentOrDirective, outputName, node, isComponent) {
		  startEvent(timeStartMap, componentOrDirective, outputName);
		  ...
		},
		onOutputEnd(componentOrDirective: any, outputName: string): void {
		  const name = outputName;
		  const entry = eventMap.get(componentOrDirective);
		  const startTimestamp = getEventStart(timeStartMap, componentOrDirective, name);
		  ...
		  const duration = performance.now() - startTimestamp;
		  entry.outputs[name] = (entry.outputs[name] || 0) + duration;
		  frameDuration += duration;
		},
	}
}

Once the entire change detection process is recorded, it’s
sent
to the frontend part for processing. The result is rendered by the
TimelineComponent.

Share this post

Sign up for our newsletter

Stay up-to-date with the trends and be a part of a thriving community.