Back to Angular

How the injector tree visualization works

devtools/docs/injector-tree.md

22.1.0-next.17.6 KB
Original Source

How the injector tree visualization works

The Injector Tree tab draws the inspected app's dependency injection hierarchy as two graphs, one for environment injectors and one for element injectors. It highlights the resolution path from a selected injector up to the root, and lists the providers configured on each injector. This doc covers the implementation: where the data comes from, how the two trees get built, and how rendering, selection, and the providers panel work. The feature needs Angular v17 or higher, because it reads framework debug APIs added in v17.

The pieces

  • Tab UI and orchestration (InjectorTreeComponent): reacts to new forest data, builds both trees, and drives selection and highlighting.
  • Tree construction: pure functions that turn resolution paths into trees.
  • Providers panel (InjectorProvidersComponent): lists and filters the selected injector's providers.
  • Renderer (TreeVisualizerComponent): a generic d3 tree renderer shared with other tabs.
  • Backend data source: reads the DI graph from the page and serializes it.
  • Framework debug APIs (ɵgetInjectorResolutionPath, ɵgetInjectorProviders, ɵgetInjectorMetadata, getInjector): the debug APIs the backend calls.

The UI and backend talk over the typed message bus. Everything below rides that channel.

Data flow

mermaid
sequenceDiagram
    participant UI as DevTools UI
    participant BE as backend
    participant NG as framework ɵ APIs

    Note over UI,NG: Build the trees
    UI->>BE: getLatestComponentExplorerView
    BE->>NG: getInjector + ɵgetInjectorResolutionPath (per element)
    NG-->>BE: injector chain per element
    BE->>NG: ɵgetInjectorMetadata / ɵgetInjectorProviders (name, type, count)
    BE-->>UI: latestComponentExplorerView (forest + resolutionPath)
    Note over UI: InjectorTreeComponent builds the
element and environment trees, then renders

    Note over UI,NG: Select an injector
    UI->>UI: highlight path to root (and the environment chain)
    UI->>BE: getInjectorProviders (id, type, name)
    BE->>NG: ɵgetInjectorProviders
    NG-->>BE: provider records
    BE-->>UI: latestInjectorProviders (serialized)
    Note over UI: injector-providers renders the table

The forest is the shared component explorer view, the same tree the Components tab reads. It gets fetched on refresh or selection, and the injector tab reacts to each latestComponentExplorerView update through its componentExplorerView input.

Where the data comes from (backend)

The backend serializes the forest in prepareForestForSerialization, attaching a resolutionPath to a node only when the DI debug APIs are available (ngDebugDependencyInjectionApiIsSupported()). Without those APIs the path is left off and the tab stays hidden.

getNodeDIResolutionPath builds the path for one node. It reads the element's injector with getInjector, walks to the root with ɵgetInjectorResolutionPath, and caches the result in the nodeInjectorToResolutionPath WeakMap so later serializations reuse it. Two cases stop early: a node with no nativeElement (for example a @defer block) returns undefined, and a component created through createComponent with a NullInjector returns an empty path, since only element injectors yield a real one.

Each injector in the path is a SerializedInjector holding:

  • id, name, and type (one of imported-module, environment, element, null, or hidden)
  • a providers count
  • an optional back-reference to the owning node

serializeInjector reads the type and name from ɵgetInjectorMetadata (#51900) and labels the platform and root environment injectors specially. getOrCreateInjectorId assigns the id, holds the injector by WeakRef in idToInjector, and registers a FinalizationRegistry so the id drops once the injector is garbage collected.

Building the two trees (frontend)

InjectorTreeComponent runs the forest through a pipeline of pure transform functions whenever a new componentExplorerView arrives.

mermaid
flowchart TB
  forest["Directive forest
(each node carries a resolutionPath)"]
  grab["grabInjectorPathsFromDirectiveForest
→ InjectorPath[]"]
  filter["optional filters:
framework injectors, empty providers"]
  split["splitInjectorPathsIntoElementAndEnvironmentPaths"]
  envTree["transformInjectorResolutionPathsIntoTree
(environment)"]
  elTree["transformInjectorResolutionPathsIntoTree
(element)"]
  render["TreeVisualizer (d3) renders each tree"]

  forest --> grab --> filter --> split
  split -->|environment paths| envTree --> render
  split -->|element paths| elTree --> render

The split step also records a map from each element path's leaf to its environment path, so selecting an element injector can light up the environment chain it falls back to. The framework-injector filter list (IGNORED_ANGULAR_INJECTORS) is hardcoded and a known stopgap, so it drifts as the framework adds directives.

The two trees live in signals with a custom areInjectorTreesEqual equality, so an identical rebuild does not trigger a re-render.

Rendering

Both trees use the shared TreeVisualizerComponent, a generic d3 renderer built on d3.hierarchy, d3.tree, and d3.zoom. The injector tab passes two hooks through its config:

  • d3InjectorTreeNodeModifier tags each SVG node with a CSS class for its injector type, a data-id holding the injector id, and a data-component-id for element injectors (the owning component's id). The synthetic root is hidden.
  • d3InjectorTreeLinkModifier tags each edge with a data-id of the form ${childId}-to-${parentId} and hides edges under the synthetic root.

Arrows point child to parent, matching the direction resolution walks. snapToNode and snapToRoot handle zoom-to-fit.

Q&A

Why two separate trees instead of one?

Angular has two injector hierarchies with different resolution rules, the environment hierarchy and the element hierarchy. Splitting each resolution path at its first element injector keeps the two on screen as distinct graphs and mirrors how resolution moves from the element tree up into the environment tree when a token is not found.

Why a synthetic hidden root?

An Angular application can have multiple roots, and d3's layout needs a single root. The N/A node gives every path one parent for layout, and the node and link modifiers hide it so users see only real injectors.

Why cache resolution paths in a WeakMap keyed by element?

An element injector's path to the root stays the same between serializations, and recomputing it on every forest dump would be wasteful. Keying the cache on the element lets the entry be collected once the element goes away.

Why hold injectors with WeakRef and a FinalizationRegistry?

The backend hands ids to the panel and has to map them back to live injectors when the user opens the providers list. A strong reference would keep destroyed injectors, and their elements, alive. The WeakRef lets them be collected, and the FinalizationRegistry removes the dead id from idToInjector.

Why collapse multi providers into one row?

A multi-provider token has one record per contributor. Listing each as its own row would repeat the token many times, so the panel emits a single multi row that carries every contributing index.

How do we know that the DI debugging is supported in the inspected app?

The backend attaches resolutionPath only when it detects the DI debug APIs are available (introduced in v17), so the tab reads that as its capability check (diDebugAPIsAvailable looks at view.forest[0].resolutionPath) rather than probing the APIs itself.