Files
Aleksander Bodurri 985fd78818 docs(devtools): document the injector tree visualization
Add an internals doc covering how the injector tree is built from the directive forest's resolution paths, rendered with d3, and how injector data is read from the DI debug APIs.
2026-06-11 13:07:08 -07:00

159 lines
7.6 KiB
Markdown

# 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<br/>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<br/>(each node carries a resolutionPath)"]
grab["grabInjectorPathsFromDirectiveForest<br/>→ InjectorPath[]"]
filter["optional filters:<br/>framework injectors, empty providers"]
split["splitInjectorPathsIntoElementAndEnvironmentPaths"]
envTree["transformInjectorResolutionPathsIntoTree<br/>(environment)"]
elTree["transformInjectorResolutionPathsIntoTree<br/>(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.