diff --git a/devtools/docs/injector-tree.md b/devtools/docs/injector-tree.md
new file mode 100644
index 00000000000..492a1764f98
--- /dev/null
+++ b/devtools/docs/injector-tree.md
@@ -0,0 +1,158 @@
+# 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.