diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md
index 14bd5fd..a6a119c 100644
--- a/docs/CONTEXT.md
+++ b/docs/CONTEXT.md
@@ -20,6 +20,10 @@ _Avoid_: content script, agent, injected script
One inspector's page-side code inside the overlay, such as `forms-collector.ts` or `pipes-collector.ts`. It reads the app through Angular's debug APIs, gives objects stable ids through a `WeakMap`, and pushes a report tagged with the `pageId`.
_Avoid_: scraper, probe, watcher
+**Host tree**:
+The tree Angular rendered into, as the component, injector, signal graph and NgRx collectors walk it: roots, children, parent and a tag for each host (`HostTree` in `host-tree.ts`). In the browser it is the DOM, through `domTree()`; a platform without a DOM describes its own views.
+_Avoid_: view tree, render tree, DOM (when the code does not depend on it)
+
**Push**:
A report a collector sends to the server over RPC, such as `push-component-tree`. A collector sends one when something changed, and a keepalive at intervals so the server knows the page is still there. Pushes stay cheap.
_Avoid_: sync, upload, post
diff --git a/docs/contributing/coding-standards.md b/docs/contributing/coding-standards.md
index cde5302..2147284 100644
--- a/docs/contributing/coding-standards.md
+++ b/docs/contributing/coding-standards.md
@@ -29,6 +29,7 @@ The overlay runs inside the user's app, so it must be correct, cheap and invisib
- **Use Angular's debug APIs**, not guesses: `ng.getComponent`, `ng.getDirectives`, `ng.getDirectiveMetadata`, `ng.getListeners`, `ng.getInjector`, `ng.ɵgetInjectorProviders`, `ng.ɵgetInjectorResolutionPath`, `ng.ɵgetDependenciesFromInjectable`, `ng.ɵgetSignalGraph`. Check the shapes in `node_modules/@angular/core/fesm2022` before relying on a field.
- **Never write to the app's DOM.** Give elements and objects ids through a `WeakMap` (`element-id.ts`, `idFor` in `injector-tree.ts`), so ids stay stable between pushes and selection survives a refresh.
+- **Walk the host tree, not the DOM**, in the component, injector, signal graph and NgRx collectors. Take a `HostTree` (`host-tree.ts`) that defaults to `domTree()`, so the same collector runs on a platform without a DOM.
- **Strip bundler prefixes** from class names (`className()` in `injector-tree.ts`), so `_App` shows as `App`.
- **Don't match `_nghost-*` or `_ngcontent-*` attributes** with exact selectors; their names carry a suffix. Start from `[ng-version]` roots and the debug APIs.
- **Keep pushes cheap.** Skip unchanged reports (compare with the last JSON sent), re-send now and then so the server doesn't expire the page, and avoid `querySelectorAll('*')` on a timer; cache and rescan on a `MutationObserver` signal.
diff --git a/packages/ng-devtools/src/__tests__/dom-walk.test.ts b/packages/ng-devtools/src/__tests__/dom-walk.test.ts
deleted file mode 100644
index ba22c58..0000000
--- a/packages/ng-devtools/src/__tests__/dom-walk.test.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-// @vitest-environment jsdom
-import { describe, expect, it } from 'vitest';
-import { parentOf, walkElements } from '../dom-walk.ts';
-
-describe('walkElements', () => {
- it('visits elements in document order, into open shadow roots', () => {
- document.body.innerHTML =
- ' ';
- const shell = document.querySelector('b-shell')!;
- shell.attachShadow({ mode: 'open' }).innerHTML = ' ';
- const names = [...walkElements(document.body)].map((el) =>
- (el as Element).tagName.toLowerCase(),
- );
- expect(names).toEqual(['body', 'a-root', 'b-shell', 'i-light', 'c-child', 'd-leaf', 'e-tail']);
- const leaf = shell.shadowRoot!.querySelector('d-leaf')!;
- expect(parentOf(parentOf(leaf)!)).toBe(shell);
- });
-
- it('includes comment nodes only when asked', () => {
- document.body.innerHTML = '
';
- expect([...walkElements(document.body)].some((n) => n.nodeType === 8)).toBe(false);
- const all = [...walkElements(document.body, true)];
- expect(all.map((n) => n.nodeName)).toEqual(['BODY', 'DIV', '#comment', 'SPAN']);
- });
-});
diff --git a/packages/ng-devtools/src/__tests__/host-tree-views.test.ts b/packages/ng-devtools/src/__tests__/host-tree-views.test.ts
new file mode 100644
index 0000000..dea0214
--- /dev/null
+++ b/packages/ng-devtools/src/__tests__/host-tree-views.test.ts
@@ -0,0 +1,150 @@
+import { describe, expect, it } from 'vitest';
+import { collectComponentTree, hostPath, type ComponentDebugNg } from '../component-tree.ts';
+import { elementId } from '../element-id.ts';
+import { hostBySelector, type HostTree } from '../host-tree.ts';
+import { collectInjectorTree } from '../injector-tree.ts';
+import { createNgrxCollector } from '../ngrx-collector.ts';
+import { collectSignalGraph, type SignalDebugNg } from '../signal-graph.ts';
+
+interface View {
+ type: string;
+ parent: View | null;
+ children: View[];
+ attached: boolean;
+}
+
+function view(type: string, children: View[] = []): View {
+ const out: View = { type, parent: null, children, attached: true };
+ for (const child of children) child.parent = out;
+ return out;
+}
+
+const viewTree = (root: View): HostTree => ({
+ roots: () => [root],
+ children: (host) => host.children,
+ parent: (host) => host.parent,
+ tag: (host) => host.type,
+ connected: (host) => host.attached,
+ isHost: (value): value is View => !!value && typeof value === 'object' && 'children' in value,
+});
+
+class App {}
+class List {}
+class Row {
+ label = 'first';
+}
+
+describe('collectors over a non-DOM host tree', () => {
+ const first = view('Row');
+ const second = view('Row');
+ const list = view('List', [view('StackLayout', [first, second])]);
+ const root = view('App', [list]);
+ const components = new Map([
+ [root, new App()],
+ [list, new List()],
+ [first, new Row()],
+ [second, new Row()],
+ ]);
+ const node = (host: View) => ({ kind: 'node', host });
+ const rootEnv = { scopes: new Set(['root']), records: new Map() };
+ const ng: ComponentDebugNg = {
+ getComponent: (host) => components.get(host) ?? null,
+ getDirectives: (host) => {
+ const component = components.get(host);
+ return component ? [component] : [];
+ },
+ getInjector: (host) => node(host),
+ ɵgetInjectorMetadata: (injector) => {
+ const inj = injector as { kind?: string; host?: View };
+ return inj.kind === 'node'
+ ? { type: 'element', source: inj.host }
+ : { type: 'environment', source: 'Environment Injector' };
+ },
+ ɵgetInjectorResolutionPath: (injector) => [injector, rootEnv],
+ ɵgetInjectorProviders: () => [],
+ };
+ const tree = viewTree(root);
+
+ it('builds the component tree, paths and detail from the views', () => {
+ const report = collectComponentTree(ng, { tree, selectedId: elementId(second) });
+ expect(report.count).toBe(4);
+ expect(report.roots[0].tag).toBe('App');
+ expect(report.roots[0].children[0].children.map((n) => n.name)).toEqual(['Row', 'Row']);
+ expect(hostPath(ng, second, tree)).toBe('App > List > Row[2]');
+ expect(report.detail?.path).toBe('App > List > Row[2]');
+ expect(report.detail?.properties.map((p) => p.name)).toEqual(['label']);
+
+ second.attached = false;
+ expect(collectComponentTree(ng, { tree, selectedId: elementId(second) }).detail).toBeNull();
+ second.attached = true;
+ });
+
+ it('builds the injector tree without selectors', () => {
+ const report = collectInjectorTree(ng, tree);
+ const app = report.roots[0];
+ expect(app.injector.name).toBe('App');
+ expect(app.injector).not.toHaveProperty('selector');
+ expect(app.children[0].children.map((n) => n.injector.name)).toEqual(['Row', 'Row']);
+ expect(report.environment.map((n) => n.injector.name)).toEqual(['Root']);
+ });
+
+ it('finds component hosts for the NgRx collector', () => {
+ const getComponent = (host: View) => components.get(host) ?? null;
+ const seen: string[] = [];
+ const collector = createNgrxCollector(
+ () => ({
+ getComponent: (host) => {
+ seen.push(host.type);
+ return getComponent(host);
+ },
+ getInjector: (host) => node(host),
+ }),
+ () => {},
+ tree,
+ );
+ collector.collect();
+ expect(seen.slice(0, 5)).toEqual(['App', 'List', 'StackLayout', 'Row', 'Row']);
+ collector.stop();
+ });
+
+ it('finds a host by selector without reordering the tree', () => {
+ const withSelectors: HostTree = {
+ ...tree,
+ selector: (host) => {
+ const parent = host.parent;
+ const own = parent
+ ? `${host.type}:nth-child(${parent.children.indexOf(host) + 1})`
+ : host.type;
+ return parent ? `${withSelectors.selector!(parent)} > ${own}` : own;
+ },
+ };
+ const order = () => list.children[0]!.children.map((host) => host === first);
+ expect(order()).toEqual([true, false]);
+ expect(
+ hostBySelector(
+ withSelectors,
+ 'App > List:nth-child(1) > StackLayout:nth-child(1) > Row:nth-child(2)',
+ ),
+ ).toBe(second);
+ expect(hostBySelector(withSelectors, 'App > Nope')).toBeNull();
+ expect(order()).toEqual([true, false]);
+ expect(hostBySelector(tree, 'App')).toBeNull();
+ });
+
+ it('collects the signal graph of a host found by id', () => {
+ const signals: SignalDebugNg = {
+ ...ng,
+ ɵgetSignalGraph: (injector) =>
+ (injector as { host?: View }).host === second
+ ? {
+ nodes: [{ id: '2', kind: 'signal', label: 'label', epoch: 1, value: 'second' }],
+ edges: [],
+ }
+ : { nodes: [], edges: [] },
+ };
+ const graph = collectSignalGraph(signals, { id: elementId(second) }, tree)!;
+ expect(graph.source).toBe('selected');
+ expect(graph.component).toMatchObject({ name: 'Row', path: 'App > List > Row[2]' });
+ expect(graph.nodes.map((n) => n.id)).toEqual(['2']);
+ });
+});
diff --git a/packages/ng-devtools/src/__tests__/host-tree.test.ts b/packages/ng-devtools/src/__tests__/host-tree.test.ts
new file mode 100644
index 0000000..0ab3fbc
--- /dev/null
+++ b/packages/ng-devtools/src/__tests__/host-tree.test.ts
@@ -0,0 +1,86 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from 'vitest';
+import { angularRoots, domTree, type HostTree } from '../host-tree.ts';
+
+function walk(tree: HostTree): H[] {
+ const out: H[] = [];
+ const visit = (host: H) => {
+ out.push(host);
+ tree.children(host).forEach(visit);
+ };
+ tree.roots().forEach(visit);
+ return out;
+}
+
+describe('domTree', () => {
+ it('walks elements in document order, into open shadow roots', () => {
+ document.body.innerHTML =
+ ' ';
+ const shell = document.querySelector('b-shell')!;
+ shell.attachShadow({ mode: 'open' }).innerHTML = ' ';
+ const tree = domTree();
+ expect(walk(tree).map((el) => tree.tag(el))).toEqual([
+ 'body',
+ 'a-root',
+ 'b-shell',
+ 'i-light',
+ 'c-child',
+ 'd-leaf',
+ 'e-tail',
+ ]);
+ const leaf = shell.shadowRoot!.querySelector('d-leaf')!;
+ expect(tree.parent(tree.parent(leaf)!)).toBe(shell);
+ expect(tree.parent(document.documentElement)).toBeNull();
+ });
+
+ it('lists ng-container anchors only when asked', () => {
+ document.body.innerHTML = '
';
+ expect(walk(domTree()).some((n) => n.nodeType === 8)).toBe(false);
+ const tree = domTree(document, { anchors: true });
+ const all = walk(tree);
+ expect(all.map((n) => n.nodeName)).toEqual(['BODY', 'DIV', '#comment', 'SPAN']);
+ const anchor = all[2];
+ expect(tree.tag(anchor)).toBe('ng-container');
+ expect(tree.isAnchor?.(anchor)).toBe(true);
+ expect(tree.isAnchor?.(all[1])).toBe(false);
+ expect(tree.selector?.(anchor)).toBeNull();
+ expect(tree.parent(anchor)).toBe(all[1]);
+ });
+
+ it('starts at the Angular roots and the elements outside them', () => {
+ document.body.innerHTML =
+ '
';
+ const roots = domTree().roots();
+ expect(roots.map((el) => el.tagName.toLowerCase())).toEqual(['app-root', 'div']);
+ expect(roots[1].className).toBe('overlay');
+ expect(angularRoots()).toEqual(roots);
+ });
+
+ it('builds selectors that find the element again, none inside a shadow root', () => {
+ document.body.innerHTML = '
';
+ const host = document.querySelector('x-host')!;
+ host.attachShadow({ mode: 'open' }).innerHTML = ' ';
+ const tree = domTree();
+ const bold = document.querySelector('b')!;
+ const selector = tree.selector!(bold)!;
+ expect(selector).toBe(
+ 'body:nth-child(2) > main:nth-child(1) > p:nth-child(2) > b:nth-child(1)',
+ );
+ expect(document.querySelector(selector)).toBe(bold);
+ expect(tree.selector!(host.shadowRoot!.querySelector('i')!)).toBeNull();
+ });
+
+ it('tells hosts from other values and tracks whether they are connected', () => {
+ document.body.innerHTML = '
';
+ const p = document.querySelector('p')!;
+ const comment = p.firstChild!;
+ expect(domTree().isHost(p)).toBe(true);
+ expect(domTree().isHost(comment)).toBe(false);
+ expect(domTree(document, { anchors: true }).isHost(comment)).toBe(true);
+ expect(domTree().isHost({})).toBe(false);
+ const tree = domTree();
+ expect(tree.connected(p)).toBe(true);
+ p.remove();
+ expect(tree.connected(p)).toBe(false);
+ });
+});
diff --git a/packages/ng-devtools/src/__tests__/ngrx-collector.test.ts b/packages/ng-devtools/src/__tests__/ngrx-collector.test.ts
index a84892b..fd3cc49 100644
--- a/packages/ng-devtools/src/__tests__/ngrx-collector.test.ts
+++ b/packages/ng-devtools/src/__tests__/ngrx-collector.test.ts
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { signal } from '@angular/core';
import { describe, expect, it, vi } from 'vitest';
+import { domTree } from '../host-tree.ts';
import { createNgrxCollector } from '../ngrx-collector.ts';
import { attachNgrx } from '../ngrx-overlay.ts';
import { diff, serialize, type NgrxPageReport } from '../ngrx-shared.ts';
@@ -78,7 +79,7 @@ function setup(maxLog?: number) {
ɵgetInjectorProviders: () => [],
};
const onChange = vi.fn();
- const collector = createNgrxCollector(() => ng as any, onChange, document, maxLog);
+ const collector = createNgrxCollector(() => ng as any, onChange, domTree(), maxLog);
return { store, app, rootEnv, collector, onChange, ng };
}
diff --git a/packages/ng-devtools/src/component-tree.ts b/packages/ng-devtools/src/component-tree.ts
index 4726a5d..9a1fc69 100644
--- a/packages/ng-devtools/src/component-tree.ts
+++ b/packages/ng-devtools/src/component-tree.ts
@@ -1,5 +1,5 @@
-import { childElements, parentOf } from './dom-walk.ts';
import { elementById, elementId, pruneElementIds } from './element-id.ts';
+import { documentTree, type HostTree } from './host-tree.ts';
import { className, dependenciesOf, type DebugNg } from './injector-tree.ts';
import { isSecretName, serializeNamed } from './serialize.ts';
import type {
@@ -9,16 +9,16 @@ import type {
LiveComponentNode,
} from './types.ts';
-export interface ComponentDebugNg extends DebugNg {
- getComponent?(el: Element): unknown;
- getDirectives?(el: Element): unknown[];
+export interface ComponentDebugNg extends DebugNg {
+ getComponent?(el: H): unknown;
+ getDirectives?(el: H): unknown[];
getDirectiveMetadata?(instance: unknown): {
inputs?: Record;
outputs?: Record;
changeDetection?: number;
encapsulation?: number;
} | null;
- getListeners?(el: Element): { name: string; type?: string }[];
+ getListeners?(el: H): { name: string; type?: string }[];
isSignal?(value: unknown): boolean;
}
@@ -48,55 +48,43 @@ function nameOf(instance: unknown): string {
return typeof ctor === 'function' ? className(ctor) : 'Anonymous';
}
-function componentAt(ng: ComponentDebugNg, el: Element): object | null {
+function componentAt(ng: ComponentDebugNg, el: H): object | null {
const found = read(() => ng.getComponent?.(el) ?? null, null);
return found && typeof found === 'object' ? found : null;
}
-function directivesAt(ng: ComponentDebugNg, el: Element): object[] {
+function directivesAt(ng: ComponentDebugNg, el: H): object[] {
const found = read(() => ng.getDirectives?.(el) ?? [], [] as unknown[]);
return found.filter((d): d is object => !!d && typeof d === 'object');
}
-export function angularRoots(doc: Document = document): Element[] {
- const tagged = Array.from(doc.querySelectorAll('[ng-version]'));
- const roots = tagged.filter((root) => !tagged.some((o) => o !== root && o.contains(root)));
- if (!doc.body) return roots;
- if (!roots.length) return [doc.body];
- const outside: Element[] = [];
- const collect = (el: Element) => {
- for (const child of childElements(el)) {
- if (roots.includes(child)) continue;
- if (roots.some((root) => child.contains(root))) collect(child);
- else outside.push(child);
- }
- };
- collect(doc.body);
- return [...roots, ...outside];
-}
-
-export function componentHosts(
- ng: ComponentDebugNg,
- doc: Document = document,
+export function componentHosts(
+ ng: ComponentDebugNg,
+ tree: HostTree = documentTree(),
limit = MAX_COMPONENTS,
-): Element[] {
- const out: Element[] = [];
- const visit = (el: Element, depth: number) => {
+): H[] {
+ const out: H[] = [];
+ const visit = (el: H, depth: number) => {
if (out.length >= limit || depth > MAX_DEPTH) return;
if (componentAt(ng, el)) out.push(el);
- for (const child of childElements(el)) visit(child, depth + 1);
+ for (const child of tree.children(el)) visit(child, depth + 1);
};
- for (const root of angularRoots(doc)) visit(root, 0);
+ for (const root of tree.roots()) visit(root, 0);
return out;
}
-function hostsUnder(ng: ComponentDebugNg, scope: Element, tagName: string): Element[] {
- const out: Element[] = [];
- const visit = (el: Element, depth: number) => {
+function hostsUnder(
+ ng: ComponentDebugNg,
+ tree: HostTree,
+ scope: H,
+ tag: string,
+): H[] {
+ const out: H[] = [];
+ const visit = (el: H, depth: number) => {
if (depth > MAX_DEPTH) return;
- for (const child of childElements(el)) {
+ for (const child of tree.children(el)) {
if (componentAt(ng, child)) {
- if (child.tagName === tagName) out.push(child);
+ if (tree.tag(child) === tag) out.push(child);
} else {
visit(child, depth + 1);
}
@@ -106,32 +94,40 @@ function hostsUnder(ng: ComponentDebugNg, scope: Element, tagName: string): Elem
return out;
}
-export function hostPath(ng: ComponentDebugNg, el: Element): string {
- const chain: Element[] = [];
- let top: Element = el;
- for (let node: Element | null = el; node; node = parentOf(node)) {
+export function hostPath(
+ ng: ComponentDebugNg,
+ el: H,
+ tree: HostTree = documentTree(),
+): string {
+ const chain: H[] = [];
+ let top: H = el;
+ for (let node: H | null = el; node; node = tree.parent(node)) {
top = node;
if (componentAt(ng, node)) chain.unshift(node);
}
return chain
.map((node, i) => {
- const tag = node.tagName.toLowerCase();
+ const tag = tree.tag(node);
const scope = i > 0 ? chain[i - 1] : top === node ? null : top;
- const twins = scope ? hostsUnder(ng, scope, node.tagName) : [node];
+ const twins = scope ? hostsUnder(ng, tree, scope, tag) : [node];
return twins.length > 1 ? `${tag}[${twins.indexOf(node) + 1}]` : tag;
})
.join(' > ');
}
-export function componentHostOf(ng: ComponentDebugNg | undefined, el: Element): Element | null {
+export function componentHostOf(
+ ng: ComponentDebugNg | undefined,
+ el: H,
+ tree: HostTree = documentTree(),
+): H | null {
if (!ng?.getComponent) return null;
- for (let node: Element | null = el; node; node = parentOf(node)) {
+ for (let node: H | null = el; node; node = tree.parent(node)) {
if (componentAt(ng, node)) return node;
}
return null;
}
-function unwrap(ng: ComponentDebugNg, value: unknown): unknown {
+function unwrap(ng: ComponentDebugNg, value: unknown): unknown {
if (typeof value !== 'function') return value;
const signal = read(() => !!ng.isSignal?.(value), false);
return signal ? read(() => (value as () => unknown)(), undefined) : value;
@@ -143,8 +139,8 @@ function propName(entry: unknown, fallback: string): string {
return fallback;
}
-function readInputs(
- ng: ComponentDebugNg,
+function readInputs(
+ ng: ComponentDebugNg,
instance: object,
inputs: Record | undefined,
): ComponentProp[] {
@@ -158,7 +154,10 @@ function readInputs(
});
}
-function resourceOf(ng: ComponentDebugNg, value: unknown): Record | null {
+function resourceOf(
+ ng: ComponentDebugNg,
+ value: unknown,
+): Record | null {
if (!value || typeof value !== 'object') return null;
const ref = value as Record;
const isSignal = (field: unknown) => read(() => !!ng.isSignal?.(field), false);
@@ -181,7 +180,11 @@ function resourceOf(ng: ComponentDebugNg, value: unknown): Record {
+function injectedValues(
+ ng: ComponentDebugNg,
+ injector: unknown,
+ owner: unknown,
+): Set {
const values = new Set();
const result = read(() => ng.ɵgetDependenciesFromInjectable?.(injector, owner) ?? null, null);
for (const dep of result?.dependencies ?? []) {
@@ -190,8 +193,8 @@ function injectedValues(ng: ComponentDebugNg, injector: unknown, owner: unknown)
return values;
}
-function readProperties(
- ng: ComponentDebugNg,
+function readProperties(
+ ng: ComponentDebugNg,
instance: object,
skip: Set,
injected: Set,
@@ -235,7 +238,12 @@ function readOutputs(
.map(([name, entry]) => ({ name, prop: propName(entry, name), listened: listened.has(name) }));
}
-export function componentDetail(ng: ComponentDebugNg, el: Element): ComponentDetail | null {
+export function componentDetail(
+ ng: ComponentDebugNg,
+ el: H,
+ tree?: HostTree,
+): ComponentDetail | null {
+ const hosts = tree ?? documentTree();
const instance = componentAt(ng, el);
if (!instance) return null;
const meta = read(() => ng.getDirectiveMetadata?.(instance) ?? null, null);
@@ -257,8 +265,8 @@ export function componentDetail(ng: ComponentDebugNg, el: Element): ComponentDet
const detail: ComponentDetail = {
id: elementId(el),
name: nameOf(instance),
- tag: el.tagName.toLowerCase(),
- path: hostPath(ng, el),
+ tag: hosts.tag(el),
+ path: hostPath(ng, el, hosts),
inputs: readInputs(ng, instance, meta?.inputs),
outputs: readOutputs(meta?.outputs, listened),
properties: readProperties(ng, instance, bound, injected),
@@ -279,20 +287,27 @@ export function componentDetail(ng: ComponentDebugNg, el: Element): ComponentDet
if (typeof enc === 'number' && ENCAPSULATION[enc]) detail.encapsulation = ENCAPSULATION[enc];
if (injector) {
- detail.dependencies = dependenciesOf(ng, injector, [instance.constructor], true);
+ detail.dependencies = dependenciesOf(
+ ng,
+ injector,
+ [instance.constructor],
+ true,
+ undefined,
+ tree,
+ );
}
return detail;
}
-export function collectComponentTree(
- ng: ComponentDebugNg | undefined,
- options: { doc?: Document; selectedId?: string | null } = {},
+export function collectComponentTree(
+ ng: ComponentDebugNg | undefined,
+ options: { tree?: HostTree; selectedId?: string | null } = {},
): Omit {
- const doc = options.doc ?? document;
+ const tree = options.tree ?? documentTree();
const report: Omit = { roots: [], count: 0, detail: null };
if (!ng?.getComponent) return report;
- const visit = (el: Element, out: LiveComponentNode[], depth: number) => {
+ const visit = (el: H, out: LiveComponentNode[], depth: number) => {
if (depth > MAX_DEPTH) {
report.truncated = true;
report.truncatedBy = { ...report.truncatedBy, depth: MAX_DEPTH };
@@ -310,7 +325,7 @@ export function collectComponentTree(
const node: LiveComponentNode = {
id: elementId(el),
name: nameOf(instance),
- tag: el.tagName.toLowerCase(),
+ tag: tree.tag(el),
children: [],
};
const directives = directivesAt(ng, el)
@@ -320,12 +335,13 @@ export function collectComponentTree(
out.push(node);
target = node.children;
}
- for (const child of childElements(el)) visit(child, target, depth + 1);
+ for (const child of tree.children(el)) visit(child, target, depth + 1);
};
- for (const root of angularRoots(doc)) visit(root, report.roots, 0);
+ for (const root of tree.roots()) visit(root, report.roots, 0);
- pruneElementIds();
- const selected = options.selectedId ? elementById(options.selectedId) : null;
- if (selected) report.detail = componentDetail(ng, selected);
+ const connected = (host: object) => tree.isHost(host) && tree.connected(host);
+ pruneElementIds(connected);
+ const selected = options.selectedId ? elementById(options.selectedId, connected) : null;
+ if (selected) report.detail = componentDetail(ng, selected, options.tree);
return report;
}
diff --git a/packages/ng-devtools/src/defer-blocks.ts b/packages/ng-devtools/src/defer-blocks.ts
index 757dddc..215e690 100644
--- a/packages/ng-devtools/src/defer-blocks.ts
+++ b/packages/ng-devtools/src/defer-blocks.ts
@@ -1,5 +1,6 @@
-import { angularRoots, componentHostOf, type ComponentDebugNg } from './component-tree.ts';
+import { componentHostOf, type ComponentDebugNg } from './component-tree.ts';
import { elementId } from './element-id.ts';
+import { angularRoots } from './host-tree.ts';
import { className } from './injector-tree.ts';
import type { DeferBlockInfo } from './types.ts';
diff --git a/packages/ng-devtools/src/dom-walk.ts b/packages/ng-devtools/src/dom-walk.ts
deleted file mode 100644
index b1c2fd9..0000000
--- a/packages/ng-devtools/src/dom-walk.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-export function childElements(el: Element): Element[] {
- const children = Array.from(el.children);
- const shadow = (el as Element & { shadowRoot?: ShadowRoot | null }).shadowRoot;
- if (shadow) children.push(...Array.from(shadow.children));
- return children;
-}
-
-export function parentOf(node: Node): Element | null {
- if (node.parentElement) return node.parentElement;
- const root = node.parentNode;
- return root && 'host' in root ? ((root as ShadowRoot).host ?? null) : null;
-}
-
-export function isComment(value: unknown): value is Comment {
- return !!value && typeof value === 'object' && (value as Node).nodeType === 8;
-}
-
-/**
- * Every element below `root` in document order, `root` included, going into
- * open shadow roots. With `comments`, the comment nodes Angular anchors an
- * `` on come along too.
- */
-export function* walkElements(root: Element, comments = false): Generator {
- const stack: (Element | Comment)[] = [root];
- while (stack.length) {
- const node = stack.pop()!;
- yield node;
- if (isComment(node)) continue;
- const children: (Element | Comment)[] = [];
- const add = (parent: ParentNode) => {
- for (const child of Array.from(parent.childNodes)) {
- if (child.nodeType === 1 || (comments && isComment(child))) {
- children.push(child as Element | Comment);
- }
- }
- };
- add(node);
- const shadow = (node as Element & { shadowRoot?: ShadowRoot | null }).shadowRoot;
- if (shadow) add(shadow);
- for (let i = children.length - 1; i >= 0; i--) stack.push(children[i]);
- }
-}
diff --git a/packages/ng-devtools/src/element-id.ts b/packages/ng-devtools/src/element-id.ts
index 3ed993c..f129d06 100644
--- a/packages/ng-devtools/src/element-id.ts
+++ b/packages/ng-devtools/src/element-id.ts
@@ -1,9 +1,13 @@
-const ids = new WeakMap();
-const byId = new Map>();
+const ids = new WeakMap();
+const byId = new Map>();
const load = Math.random().toString(36).slice(2, 6).padEnd(4, '0');
let nextId = 0;
-export function elementId(el: Element): string {
+type Connected = (host: object) => boolean;
+
+const isConnected: Connected = (host) => (host as { isConnected?: unknown }).isConnected === true;
+
+export function elementId(el: object): string {
let id = ids.get(el);
if (!id) {
id = `c${load}-${++nextId}`;
@@ -13,18 +17,20 @@ export function elementId(el: Element): string {
return id;
}
-export function elementById(id: string): Element | null {
+export function elementById(id: string): Element | null;
+export function elementById(id: string, connected: Connected): H | null;
+export function elementById(id: string, connected: Connected = isConnected): object | null {
const el = byId.get(id)?.deref();
if (!el) {
byId.delete(id);
return null;
}
- return el.isConnected ? el : null;
+ return connected(el) ? el : null;
}
-export function pruneElementIds() {
+export function pruneElementIds(connected: Connected = isConnected) {
for (const [id, ref] of byId) {
const el = ref.deref();
- if (!el || !el.isConnected) byId.delete(id);
+ if (!el || !connected(el)) byId.delete(id);
}
}
diff --git a/packages/ng-devtools/src/host-tree.ts b/packages/ng-devtools/src/host-tree.ts
new file mode 100644
index 0000000..a139bf1
--- /dev/null
+++ b/packages/ng-devtools/src/host-tree.ts
@@ -0,0 +1,142 @@
+/**
+ * The tree Angular rendered into, as the collectors walk it. In the browser it
+ * is the DOM (`domTree()`); a platform without one, such as NativeScript or
+ * Angular Native, describes its own views instead.
+ */
+export interface HostTree {
+ /** Where a walk of the app starts, in render order. */
+ roots(): H[];
+ /** The direct children of a host, in render order. */
+ children(host: H): H[];
+ /** The host this one renders in, or null at the top. */
+ parent(host: H): H | null;
+ /** What a host is called in a component path: a tag name or a view type. */
+ tag(host: H): string;
+ /** Whether the host is still part of the rendered tree. */
+ connected(host: H): boolean;
+ /** Whether a value, such as the source of an element injector, is a host of this tree. */
+ isHost(value: unknown): value is H;
+ /** A query that finds the host again, or null when it has none. */
+ selector?(host: H): string | null;
+ /**
+ * Whether the host renders nothing itself, such as the comment Angular
+ * anchors an `` on. It can carry directives but no component.
+ */
+ isAnchor?(host: H): boolean;
+}
+
+export function childElements(el: Element): Element[] {
+ const children = Array.from(el.children);
+ const shadow = (el as Element & { shadowRoot?: ShadowRoot | null }).shadowRoot;
+ if (shadow) children.push(...Array.from(shadow.children));
+ return children;
+}
+
+export function parentOf(node: Node): Element | null {
+ if (node.parentElement) return node.parentElement;
+ const root = node.parentNode;
+ return root && 'host' in root ? ((root as ShadowRoot).host ?? null) : null;
+}
+
+export function isComment(value: unknown): value is Comment {
+ return !!value && typeof value === 'object' && (value as Node).nodeType === 8;
+}
+
+function childNodes(el: Element): (Element | Comment)[] {
+ const out: (Element | Comment)[] = [];
+ const add = (parent: ParentNode) => {
+ for (const child of Array.from(parent.childNodes)) {
+ if (child.nodeType === 1 || isComment(child)) out.push(child as Element | Comment);
+ }
+ };
+ add(el);
+ const shadow = (el as Element & { shadowRoot?: ShadowRoot | null }).shadowRoot;
+ if (shadow) add(shadow);
+ return out;
+}
+
+export function angularRoots(doc: Document = document): Element[] {
+ const tagged = Array.from(doc.querySelectorAll('[ng-version]'));
+ const roots = tagged.filter((root) => !tagged.some((o) => o !== root && o.contains(root)));
+ if (!doc.body) return roots;
+ if (!roots.length) return [doc.body];
+ const outside: Element[] = [];
+ const collect = (el: Element) => {
+ for (const child of childElements(el)) {
+ if (roots.includes(child)) continue;
+ if (roots.some((root) => child.contains(root))) collect(child);
+ else outside.push(child);
+ }
+ };
+ collect(doc.body);
+ return [...roots, ...outside];
+}
+
+function selectorCache(doc: Document) {
+ const selectors = new Map();
+ const positions = new Map();
+ const top = doc.documentElement;
+ const selectorOf = (el: Element): string | null => {
+ if (el === top) return '';
+ const known = selectors.get(el);
+ if (known !== undefined) return known;
+ const parent = el.parentElement;
+ const tag = el.tagName.toLowerCase();
+ let out: string | null = el.parentNode === doc ? tag : null;
+ if (parent) {
+ if (!positions.has(el)) {
+ let index = 0;
+ for (const child of Array.from(parent.children)) positions.set(child, ++index);
+ }
+ const prefix = selectorOf(parent);
+ const part = `${tag}:nth-child(${positions.get(el)})`;
+ out = prefix === null ? null : prefix ? `${prefix} > ${part}` : part;
+ }
+ selectors.set(el, out);
+ return out;
+ };
+ return selectorOf;
+}
+
+/**
+ * The DOM of `doc`, going into open shadow roots. With `anchors`, the comments
+ * Angular anchors an `` on are hosts too, named `ng-container`.
+ * Selectors are cached, so take a fresh tree for each collection.
+ */
+export function domTree(doc?: Document): HostTree;
+export function domTree(doc: Document, options: { anchors: true }): HostTree;
+export function domTree(
+ doc: Document = document,
+ options: { anchors?: boolean } = {},
+): HostTree {
+ const selectorOf = selectorCache(doc);
+ return {
+ roots: () => angularRoots(doc),
+ children: (host) =>
+ isComment(host) ? [] : options.anchors ? childNodes(host) : childElements(host),
+ parent: parentOf,
+ tag: (host) => (isComment(host) ? 'ng-container' : host.tagName.toLowerCase()),
+ connected: (host) => host.isConnected,
+ isHost: (value): value is Element | Comment =>
+ (typeof Element !== 'undefined' && value instanceof Element) ||
+ (!!options.anchors && isComment(value)),
+ selector: (host) => (isComment(host) ? null : selectorOf(host)),
+ isAnchor: isComment,
+ };
+}
+
+/** The DOM tree, for a collector whose host type is a parameter that defaults to Element. */
+export function documentTree(): HostTree {
+ return domTree() as unknown as HostTree;
+}
+
+export function hostBySelector(tree: HostTree, selector: string): H | null {
+ if (!tree.selector) return null;
+ const stack = [...tree.roots()].reverse();
+ while (stack.length) {
+ const host = stack.pop()!;
+ if (tree.selector(host) === selector) return host;
+ stack.push(...[...tree.children(host)].reverse());
+ }
+ return null;
+}
diff --git a/packages/ng-devtools/src/injector-tree.ts b/packages/ng-devtools/src/injector-tree.ts
index b54ac6d..2768bf2 100644
--- a/packages/ng-devtools/src/injector-tree.ts
+++ b/packages/ng-devtools/src/injector-tree.ts
@@ -5,7 +5,7 @@ import type {
InjectorTreeReport,
ProviderInfo,
} from './types.ts';
-import { isComment, parentOf, walkElements } from './dom-walk.ts';
+import { domTree, type HostTree } from './host-tree.ts';
import { zoneModeOf } from './zone-mode.ts';
interface ProviderRecord {
@@ -15,10 +15,10 @@ interface ProviderRecord {
importPath?: unknown[];
}
-export interface DebugNg {
- getInjector?(el: Element): unknown;
- getComponent?(el: Element): unknown;
- getDirectives?(node: Node): unknown[];
+export interface DebugNg {
+ getInjector?(el: H): unknown;
+ getComponent?(el: H): unknown;
+ getDirectives?(node: H): unknown[];
ɵgetInjectorMetadata?(injector: unknown): { type: string; source: unknown } | null;
ɵgetInjectorProviders?(injector: unknown): ProviderRecord[];
ɵgetInjectorResolutionPath?(injector: unknown): unknown[];
@@ -81,7 +81,7 @@ function isBuiltInElementToken(record: ProviderRecord): boolean {
type RecordReader = (injector: unknown) => ProviderRecord[];
-function recordReader(ng: DebugNg): RecordReader {
+function recordReader(ng: DebugNg): RecordReader {
const cache = new Map();
return (injector) => {
let list = cache.get(injector);
@@ -113,32 +113,6 @@ function toProviders(injector: unknown, records: RecordReader): ProviderInfo[] {
});
}
-function selectorCache(doc: Document) {
- const selectors = new Map();
- const positions = new Map();
- const top = doc.documentElement;
- const selectorOf = (el: Element): string | null => {
- if (el === top) return '';
- const known = selectors.get(el);
- if (known !== undefined) return known;
- const parent = el.parentElement;
- const tag = el.tagName.toLowerCase();
- let out: string | null = el.parentNode === doc ? tag : null;
- if (parent) {
- if (!positions.has(el)) {
- let index = 0;
- for (const child of Array.from(parent.children)) positions.set(child, ++index);
- }
- const prefix = selectorOf(parent);
- const part = `${tag}:nth-child(${positions.get(el)})`;
- out = prefix === null ? null : prefix ? `${prefix} > ${part}` : part;
- }
- selectors.set(el, out);
- return out;
- };
- return selectorOf;
-}
-
function environmentName(injector: unknown, source: unknown): string {
const scopes = (injector as { scopes?: Set } | null)?.scopes;
if (scopes?.has('platform')) return 'Platform';
@@ -149,7 +123,17 @@ function environmentName(injector: unknown, source: unknown): string {
export const NULL_INJECTOR_ID = 'inj-null';
-export function injectorRef(ng: DebugNg, injector: unknown): { id: string; name: string } | null {
+type HostNames = Pick, 'isHost' | 'tag'>;
+
+function pageTree(): HostTree {
+ return domTree(document, { anchors: true }) as unknown as HostTree;
+}
+
+export function injectorRef(
+ ng: DebugNg,
+ injector: unknown,
+ hosts: HostNames = pageTree(),
+): { id: string; name: string } | null {
if (!injector || typeof injector !== 'object') return null;
let meta: { type: string; source: unknown } | null = null;
try {
@@ -159,10 +143,7 @@ export function injectorRef(ng: DebugNg, injector: unknown): { id: string; name:
}
if (meta?.type === 'element') {
const source = meta.source;
- if (source instanceof Element) {
- return { id: idFor(source), name: `<${source.tagName.toLowerCase()}>` };
- }
- return isComment(source) ? { id: idFor(source), name: '' } : null;
+ return hosts.isHost(source) ? { id: idFor(source), name: `<${hosts.tag(source)}>` } : null;
}
if (meta?.type === 'null') return { id: NULL_INJECTOR_ID, name: 'Null injector' };
return { id: idFor(injector), name: environmentName(injector, meta?.source) };
@@ -175,8 +156,8 @@ type DependencyFlags = { optional?: boolean; host?: boolean; self?: boolean; ski
* `ɵgetDependenciesFromInjectable` treats a provider whose value is `null` as
* missing, so a `useValue: null` provider is found here instead.
*/
-function listedOnPath(
- ng: DebugNg,
+function listedOnPath(
+ ng: DebugNg,
path: unknown[],
records: RecordReader,
token: unknown,
@@ -200,12 +181,13 @@ function listedOnPath(
return null;
}
-export function dependenciesOf(
- ng: DebugNg,
+export function dependenciesOf(
+ ng: DebugNg,
injector: unknown,
owners: Iterable,
withNames = false,
records: RecordReader = recordReader(ng),
+ hosts: HostNames = pageTree(),
): DependencyInfo[] {
const out: DependencyInfo[] = [];
let path: unknown[] | undefined;
@@ -230,7 +212,7 @@ export function dependenciesOf(
.map(([flag]) => flag);
const providedIn =
dep.providedIn ?? listedOnPath(ng, lookupPath(), records, dep.token, dep.flags ?? {});
- const by = providedIn ? injectorRef(ng, providedIn) : null;
+ const by = providedIn ? injectorRef(ng, providedIn, hosts) : null;
const info: DependencyInfo = {
from: className(ctor),
token: tokenName(dep.token),
@@ -291,10 +273,11 @@ function isCreated(record: InjectorRecord): boolean {
const serviceDependencies = new WeakMap>();
/** What the services an environment injector already created inject. */
-function environmentDependencies(
- ng: DebugNg,
+function environmentDependencies(
+ ng: DebugNg,
injector: object,
records: RecordReader,
+ hosts: HostNames,
): DependencyInfo[] {
let known = serviceDependencies.get(injector);
if (!known) {
@@ -309,7 +292,7 @@ function environmentDependencies(
if (record.multi || !isCreated(record)) continue;
let deps = known.get(token);
if (!deps) {
- deps = dependenciesOf(ng, injector, [token], false, records);
+ deps = dependenciesOf(ng, injector, [token], false, records, hosts);
known.set(token, deps);
}
for (const dep of deps) {
@@ -322,9 +305,9 @@ function environmentDependencies(
return out.slice(0, MAX_SERVICE_DEPENDENCIES);
}
-const elementEntries = new WeakMap();
+const elementEntries = new WeakMap();
-function environmentsOf(ng: DebugNg, path: unknown[]): object[] {
+function environmentsOf(ng: DebugNg, path: unknown[]): object[] {
return path.filter((injector) => {
try {
return ng.ɵgetInjectorMetadata!(injector)?.type === 'environment';
@@ -334,17 +317,18 @@ function environmentsOf(ng: DebugNg, path: unknown[]): object[] {
}) as object[];
}
-function readElement(
- ng: DebugNg,
- el: Element | Comment,
+function readElement(
+ ng: DebugNg,
+ el: H,
records: RecordReader,
+ tree: HostTree,
): ElementEntry | null {
const cached = elementEntries.get(el);
if (cached) return cached;
let component: unknown = null;
let directives: unknown[] = [];
try {
- component = isComment(el) ? null : (ng.getComponent?.(el) ?? null);
+ component = tree.isAnchor?.(el) ? null : (ng.getComponent?.(el) ?? null);
directives = ng.getDirectives?.(el) ?? [];
} catch {
return null;
@@ -353,7 +337,7 @@ function readElement(
let injector: unknown;
try {
- injector = ng.getInjector!(el as Element);
+ injector = ng.getInjector!(el);
} catch {
return null;
}
@@ -376,27 +360,27 @@ function readElement(
const info: Omit = {
id: idFor(el),
type: 'element',
- name: isComment(el) ? 'ng-container' : el.tagName.toLowerCase(),
+ name: tree.tag(el),
providerCount: providers.length,
directives: [...owners].map(className),
path: path
- .map((entry) => injectorRef(ng, entry)?.id ?? null)
+ .map((entry) => injectorRef(ng, entry, tree)?.id ?? null)
.filter((id): id is string => !!id),
};
if (typeof componentCtor === 'function') info.component = className(componentCtor);
const entry: ElementEntry = {
info,
providers,
- dependencies: dependenciesOf(ng, injector, owners, false, records),
+ dependencies: dependenciesOf(ng, injector, owners, false, records, tree),
environments: environmentsOf(ng, path),
};
elementEntries.set(el, entry);
return entry;
}
-export function collectInjectorTree(
- ng: DebugNg | undefined,
- doc: Document = document,
+export function collectInjectorTree(
+ ng: DebugNg | undefined,
+ tree: HostTree = pageTree(),
): InjectorTreeReport {
const empty: InjectorTreeReport = { roots: [], environment: [] };
if (!ng?.getInjector || !ng.ɵgetInjectorMetadata) return empty;
@@ -424,39 +408,46 @@ export function collectInjectorTree(
},
providers,
children: [],
- dependencies: environmentDependencies(ng, injector, records),
+ dependencies: environmentDependencies(ng, injector, records, tree),
},
});
});
};
- const selectorOf = selectorCache(doc);
- const elementNodes = new Map();
const roots: InjectorTreeNode[] = [];
+ let count = 0;
let truncated = false;
-
- for (const el of walkElements(doc.body ?? doc.documentElement, true)) {
- const entry = readElement(ng, el, records);
- if (!entry) continue;
- if (elementNodes.size >= MAX_INJECTOR_NODES) {
- truncated = true;
- break;
+ const stack = tree
+ .roots()
+ .map((el) => ({ el, into: roots }))
+ .reverse();
+
+ while (stack.length) {
+ const { el, into } = stack.pop()!;
+ let childrenInto = into;
+ const entry = readElement(ng, el, records, tree);
+ if (entry) {
+ if (count >= MAX_INJECTOR_NODES) {
+ truncated = true;
+ break;
+ }
+ count++;
+ noteEnvironment(entry.environments);
+
+ const selector = tree.selector?.(el) ?? null;
+ const node: InjectorTreeNode = {
+ injector: selector === null ? { ...entry.info } : { ...entry.info, selector },
+ providers: entry.providers,
+ children: [],
+ dependencies: entry.dependencies,
+ };
+ into.push(node);
+ childrenInto = node.children;
+ }
+ const children = tree.children(el);
+ for (let i = children.length - 1; i >= 0; i--) {
+ stack.push({ el: children[i], into: childrenInto });
}
- noteEnvironment(entry.environments);
-
- const selector = isComment(el) ? null : selectorOf(el);
- const node: InjectorTreeNode = {
- injector: selector === null ? { ...entry.info } : { ...entry.info, selector },
- providers: entry.providers,
- children: [],
- dependencies: entry.dependencies,
- };
- elementNodes.set(el, node);
-
- let parent = parentOf(el);
- while (parent && !elementNodes.has(parent)) parent = parentOf(parent);
- if (parent) elementNodes.get(parent)!.children.push(node);
- else roots.push(node);
}
const environment: InjectorTreeNode[] = [];
diff --git a/packages/ng-devtools/src/ngrx-collector.ts b/packages/ng-devtools/src/ngrx-collector.ts
index c399a00..601dacc 100644
--- a/packages/ng-devtools/src/ngrx-collector.ts
+++ b/packages/ng-devtools/src/ngrx-collector.ts
@@ -1,5 +1,5 @@
import { untracked } from '@angular/core';
-import { walkElements } from './dom-walk.ts';
+import { documentTree, type HostTree } from './host-tree.ts';
import { className, tokenName } from './injector-tree.ts';
import {
diff,
@@ -21,9 +21,9 @@ import { registeredPatchState } from './ngrx-register.ts';
type AnyRecord = Record;
-export interface NgrxDebugNg {
- getInjector?(el: Element): unknown;
- getComponent?(el: Element): unknown;
+export interface NgrxDebugNg {
+ getInjector?(el: H): unknown;
+ getComponent?(el: H): unknown;
ɵgetInjectorMetadata?(injector: unknown): { type: string; source: unknown } | null;
ɵgetInjectorProviders?(injector: unknown): { token: unknown; isViewProvider?: boolean }[];
ɵgetInjectorResolutionPath?(injector: unknown): unknown[];
@@ -107,15 +107,18 @@ function stripped(token: unknown): string {
return tokenName(token).replace(/^_+/, '');
}
-function componentElements(ng: NgrxDebugNg, doc: Document): Element[] {
- const out: Element[] = [];
- for (const el of walkElements(doc.body ?? doc.documentElement) as Generator) {
+function componentElements(ng: NgrxDebugNg, tree: HostTree): H[] {
+ const out: H[] = [];
+ const stack = [...tree.roots()].reverse();
+ while (stack.length) {
+ const el = stack.pop()!;
if (read(() => !!ng.getComponent?.(el), false)) out.push(el);
+ stack.push(...[...tree.children(el)].reverse());
}
return out;
}
-function envScope(ng: NgrxDebugNg, injector: AnyRecord): string {
+function envScope(ng: NgrxDebugNg, injector: AnyRecord): string {
if (read(() => injector['scopes']?.has?.('root'), false)) return 'root';
if (read(() => injector['scopes']?.has?.('platform'), false)) return 'platform';
const source = read(() => ng.ɵgetInjectorMetadata?.(injector)?.source, undefined);
@@ -178,10 +181,10 @@ export interface NgrxCollector {
stop(): void;
}
-export function createNgrxCollector(
- getNg: () => NgrxDebugNg | undefined,
+export function createNgrxCollector(
+ getNg: () => NgrxDebugNg | undefined,
onChange: () => void,
- doc: Document = document,
+ tree: HostTree = documentTree(),
maxLog = MAX_LOG,
): NgrxCollector {
const ids = new WeakMap();
@@ -344,7 +347,7 @@ export function createNgrxCollector(
return t;
};
- const findClassic = (ng: NgrxDebugNg, envs: Map, rootInjector: unknown) => {
+ const findClassic = (ng: NgrxDebugNg, envs: Map, rootInjector: unknown) => {
const want: Record boolean> = {
Store: (v) => typeof v['dispatch'] === 'function' && typeof v['select'] === 'function',
ScannedActionsSubject: (v) => typeof v['subscribe'] === 'function',
@@ -573,13 +576,13 @@ export function createNgrxCollector(
};
let discovered = false;
- const discover = (ng: NgrxDebugNg) => {
+ const discover = (ng: NgrxDebugNg) => {
const found = new Set();
const envs = new Map();
- const elements = componentElements(ng, doc);
+ const elements = componentElements(ng, tree);
let rootInjector: unknown = null;
- const perElement: { el: Element; injector: unknown; component: AnyRecord | null }[] = [];
+ const perElement: { el: H; injector: unknown; component: AnyRecord | null }[] = [];
for (const el of elements) {
const injector = read(() => ng.getInjector!(el), null);
if (!injector) continue;
@@ -609,7 +612,7 @@ export function createNgrxCollector(
}
for (const { el, injector, component } of perElement) {
- const owner = component ? className(component.constructor) : el.tagName.toLowerCase();
+ const owner = component ? className(component.constructor) : tree.tag(el);
for (const p of read(() => ng.ɵgetInjectorProviders?.(injector) ?? [], [])) {
if (!/^SignalStore\d*$/.test(stripped(p.token))) continue;
const value = read(
diff --git a/packages/ng-devtools/src/ngrx-overlay.ts b/packages/ng-devtools/src/ngrx-overlay.ts
index 06a9e48..94249d4 100644
--- a/packages/ng-devtools/src/ngrx-overlay.ts
+++ b/packages/ng-devtools/src/ngrx-overlay.ts
@@ -1,3 +1,4 @@
+import { documentTree, type HostTree } from './host-tree.ts';
import { createNgrxCollector, type NgrxDebugNg } from './ngrx-collector.ts';
import type { NgrxPageReport, NgrxRequest } from './ngrx-shared.ts';
@@ -16,11 +17,17 @@ interface RpcScope {
const HEARTBEAT_MS = 5000;
-export function attachNgrx(
+export interface NgrxPageOptions {
+ tree?: HostTree;
+ describe?: () => { url: string; title: string };
+}
+
+export function attachNgrx(
my: RpcScope,
pageId: string,
- getNg: () => NgrxDebugNg | undefined,
+ getNg: () => NgrxDebugNg | undefined,
maxLog?: number,
+ options: NgrxPageOptions = {},
) {
const session = Math.random().toString(36).slice(2, 10);
let sentSeq = 0;
@@ -41,7 +48,12 @@ export function attachNgrx(
}, 50);
};
- const collector = createNgrxCollector(getNg, () => schedule(), document, maxLog);
+ const collector = createNgrxCollector(
+ getNg,
+ () => schedule(),
+ options.tree ?? documentTree(),
+ maxLog,
+ );
const push = async (rediscover = true) => {
if (pushing) return schedule(rediscover);
@@ -56,11 +68,14 @@ export function attachNgrx(
if (quiet && body === lastBody && Date.now() - lastPushAt < HEARTBEAT_MS) return;
lastBody = body;
lastPushAt = Date.now();
+ const page = options.describe?.() ?? {
+ url: location.pathname + location.search,
+ title: document.title,
+ };
const report: NgrxPageReport = {
pageId,
session,
- url: location.pathname + location.search,
- title: document.title,
+ ...page,
stores,
classic,
log,
diff --git a/packages/ng-devtools/src/overlay.ts b/packages/ng-devtools/src/overlay.ts
index 90e3eb6..5121359 100644
--- a/packages/ng-devtools/src/overlay.ts
+++ b/packages/ng-devtools/src/overlay.ts
@@ -34,7 +34,8 @@ import {
storedInstrumented,
type PreloadRecord,
} from './router-actions.ts';
-import { createSignalHistory, type RawSignalNode } from './signal-history.ts';
+import { createSignalHistory, installSignalWriteHook } from './signal-history.ts';
+export { installSignalWriteHook } from './signal-history.ts';
import { collectComponentTree, componentHostOf } from './component-tree.ts';
import { startComponentPick } from './component-pick.ts';
import { createDeferTracker } from './defer-blocks.ts';
@@ -688,42 +689,6 @@ function findAngularElements(): Element[] {
return Array.from(new Set([...versionEls, ...hostEls]));
}
-// --- Signal Graph collection using Angular's debug API ---
-type SignalSetHook = ((node: RawSignalNode) => void) | null;
-
-export async function installSignalWriteHook(
- onWrite: (node: RawSignalNode) => void,
- load: () => Promise<{ setPostSignalSetFn: (fn: SignalSetHook) => SignalSetHook }> = () =>
- import('@angular/core/primitives/signals') as never,
-): Promise<(() => void) | null> {
- let setHook: (fn: SignalSetHook) => SignalSetHook;
- try {
- ({ setPostSignalSetFn: setHook } = await load());
- } catch {
- // Without the hook, history falls back to poll samples only.
- return null;
- }
- if (typeof setHook !== 'function') return null;
- let prev: SignalSetHook = null;
- let active = true;
- const hook = (node: RawSignalNode) => {
- prev?.(node);
- if (!active) return;
- try {
- onWrite(node);
- } catch {
- return;
- }
- };
- prev = setHook(hook);
- return () => {
- active = false;
- const current = setHook(prev);
- // Someone chained after us; keep theirs, our hook now just forwards.
- if (current !== hook) setHook(current);
- };
-}
-
function read(fn: () => T, fallback: T): T {
try {
return fn();
diff --git a/packages/ng-devtools/src/signal-graph.ts b/packages/ng-devtools/src/signal-graph.ts
index 80a6222..a1b5e6e 100644
--- a/packages/ng-devtools/src/signal-graph.ts
+++ b/packages/ng-devtools/src/signal-graph.ts
@@ -1,5 +1,6 @@
import { componentHosts, hostPath, type ComponentDebugNg } from './component-tree.ts';
import { elementById, elementId } from './element-id.ts';
+import { domTree, hostBySelector, type HostTree } from './host-tree.ts';
import { className, injectorRef } from './injector-tree.ts';
import { serializeNamed } from './serialize.ts';
import { cleanValue, groupResources, rawNodeOf, type RawNode } from './signal-resources.ts';
@@ -11,7 +12,7 @@ import type {
SignalNodeKind,
} from './types.ts';
-export interface SignalDebugNg extends ComponentDebugNg {
+export interface SignalDebugNg extends ComponentDebugNg {
ɵgetSignalGraph?(injector: unknown): {
nodes: { id?: string; kind?: string; label?: string; epoch?: number; value?: unknown }[];
edges?: SignalGraphEdge[];
@@ -37,7 +38,7 @@ function read(fn: () => T, fallback: T): T {
}
}
-function isComponentHost(ng: SignalDebugNg, el: Element | null): el is Element {
+function isComponentHost(ng: SignalDebugNg, el: H | null): el is H {
return !!el && !!read(() => ng.getComponent?.(el), null);
}
@@ -59,14 +60,47 @@ export function toSignalTarget(request: unknown, pageId: string): SignalTarget |
return typeof selector === 'string' && selector && selector.length < 500 ? { selector } : null;
}
-function resolveTarget(target: SignalTarget, doc: Document): Element | null {
+interface HostSource {
+ tree: HostTree;
+ find(target: { id: string } | { selector: string }): H | null;
+ routed(ng: SignalDebugNg): H | null;
+}
+
+function pageSource(doc: Document): HostSource {
+ return {
+ tree: domTree(doc),
+ find: (target) => {
+ if ('id' in target) return elementById(target.id);
+ try {
+ return doc.querySelector(target.selector);
+ } catch {
+ return null;
+ }
+ },
+ routed: (ng) => routedComponent(ng, doc),
+ };
+}
+
+function treeSource(tree: HostTree): HostSource {
+ return {
+ tree,
+ find: (target) =>
+ 'id' in target
+ ? elementById(target.id, (host) => tree.isHost(host) && tree.connected(host))
+ : hostBySelector(tree, target.selector),
+ routed: () => null,
+ };
+}
+
+function sourceOf(from: Document | HostTree): HostSource {
+ return 'roots' in from && typeof from.roots === 'function'
+ ? treeSource(from)
+ : (pageSource(from as Document) as unknown as HostSource);
+}
+
+function resolveTarget(target: SignalTarget, source: HostSource): H | null {
if (!target || 'env' in target) return null;
- if ('id' in target) return elementById(target.id);
- try {
- return doc.querySelector(target.selector);
- } catch {
- return null;
- }
+ return source.find(target);
}
function isPrimaryOutlet(outlet: Element): boolean {
@@ -110,7 +144,10 @@ interface LinkedReader {
node: RawNode;
}
-function linkedSignalReaders(ng: SignalDebugNg, instance: object): LinkedReader[] {
+function linkedSignalReaders(
+ ng: SignalDebugNg,
+ instance: object,
+): LinkedReader[] {
const readers: LinkedReader[] = [];
for (const key of read(() => Object.keys(instance), [] as string[])) {
const value = read(() => (instance as Record)[key], undefined);
@@ -143,7 +180,11 @@ function linkedValue(
type RawGraph = NonNullable>>;
-function buildGraph(raw: RawGraph, instance: object | null, ng: SignalDebugNg) {
+function buildGraph(
+ raw: RawGraph,
+ instance: object | null,
+ ng: SignalDebugNg,
+) {
let linked: LinkedReader[] | null = null;
const kept = raw.nodes.slice(0, MAX_NODES);
const twins = new Map();
@@ -189,17 +230,18 @@ function hasIds(raw: RawGraph): boolean {
return raw.nodes.every((n) => typeof n.id === 'string' || typeof n.id === 'number');
}
-function rawGraph(ng: SignalDebugNg, injector: unknown): RawGraph | null {
+function rawGraph(ng: SignalDebugNg, injector: unknown): RawGraph | null {
const raw = read(() => ng.ɵgetSignalGraph?.(injector) ?? null, null);
return raw && Array.isArray(raw.nodes) ? raw : null;
}
const UNSUPPORTED: SignalGraph = { nodes: [], edges: [], unsupported: true };
-function graphFor(
- ng: SignalDebugNg,
- el: Element,
+function graphFor(
+ ng: SignalDebugNg,
+ el: H,
source: NonNullable,
+ tree: HostTree,
): SignalGraph | null {
const instance = read(() => ng.getComponent?.(el), null);
if (!instance || typeof instance !== 'object') return null;
@@ -208,7 +250,7 @@ function graphFor(
const raw = rawGraph(ng, injector);
if (!raw) return null;
if (!hasIds(raw)) return UNSUPPORTED;
- const tag = el.tagName.toLowerCase();
+ const tag = tree.tag(el);
return {
...buildGraph(raw, instance, ng),
componentSelector: tag,
@@ -216,7 +258,7 @@ function graphFor(
id: elementId(el),
name: className((instance as { constructor: new () => unknown }).constructor),
tag,
- path: hostPath(ng, el),
+ path: hostPath(ng, el, tree),
},
source,
};
@@ -226,7 +268,11 @@ interface Environment extends SignalGraphInjector {
injector: object;
}
-function environmentsFor(ng: SignalDebugNg, hosts: Element[]): Environment[] {
+function environmentsFor(
+ ng: SignalDebugNg,
+ hosts: H[],
+ tree: HostTree,
+): Environment[] {
const out = new Map();
for (const host of hosts) {
const injector = read(() => ng.getInjector?.(host), null);
@@ -238,7 +284,7 @@ function environmentsFor(ng: SignalDebugNg, hosts: Element[]): Environment[] {
if (meta?.type !== 'environment') continue;
const scopes = (entry as { scopes?: Set }).scopes;
if (read(() => scopes?.has('platform') ?? false, false)) continue;
- const ref = injectorRef(ng, entry);
+ const ref = injectorRef(ng, entry, tree);
if (!ref) continue;
out.set(entry, { ...ref, injector: entry });
}
@@ -269,7 +315,10 @@ export function isEnvironmentRequest(wanted: string): boolean {
);
}
-function graphForEnvironment(ng: SignalDebugNg, env: Environment): SignalGraph | null {
+function graphForEnvironment(
+ ng: SignalDebugNg,
+ env: Environment,
+): SignalGraph | null {
const raw = rawGraph(ng, env.injector);
if (!raw) return null;
if (!hasIds(raw)) return UNSUPPORTED;
@@ -280,32 +329,33 @@ function graphForEnvironment(ng: SignalDebugNg, env: Environment): SignalGraph |
};
}
-function environmentHosts(ng: SignalDebugNg, doc: Document): Element[] {
- const hosts = componentHosts(ng, doc, 1);
- const routed = routedComponent(ng, doc);
+function environmentHosts(ng: SignalDebugNg, source: HostSource): H[] {
+ const hosts = componentHosts(ng, source.tree, 1);
+ const routed = source.routed(ng);
return routed ? [...hosts, routed] : hosts;
}
-export function collectSignalGraph(
- ng: SignalDebugNg | undefined,
+export function collectSignalGraph(
+ ng: SignalDebugNg | undefined,
target: SignalTarget = null,
- doc: Document = document,
+ from: Document | HostTree = document,
): SignalGraph | null {
if (!ng?.ɵgetSignalGraph || !ng.getInjector || !ng.getComponent) return null;
+ const source = sourceOf(from);
const environments = ng.ɵgetInjectorResolutionPath
- ? environmentsFor(ng, environmentHosts(ng, doc))
+ ? environmentsFor(ng, environmentHosts(ng, source), source.tree)
: [];
- const graph = pickGraph(ng, target, doc, environments);
+ const graph = pickGraph(ng, target, source, environments);
if (graph && !graph.unsupported && environments.length) {
return { ...graph, environments: environments.map(({ id, name }) => ({ id, name })) };
}
return graph;
}
-function pickGraph(
- ng: SignalDebugNg,
+function pickGraph(
+ ng: SignalDebugNg,
target: SignalTarget,
- doc: Document,
+ source: HostSource,
environments: Environment[],
): SignalGraph | null {
if (target && 'env' in target) {
@@ -313,19 +363,19 @@ function pickGraph(
const graph = env ? graphForEnvironment(ng, env) : null;
if (graph) return graph;
}
- const picked = resolveTarget(target, doc);
+ const picked = resolveTarget(target, source);
if (isComponentHost(ng, picked)) {
- const graph = graphFor(ng, picked, 'selected');
+ const graph = graphFor(ng, picked, 'selected', source.tree);
if (graph) return graph;
}
- const routed = routedComponent(ng, doc);
+ const routed = source.routed(ng);
if (routed) {
- const graph = graphFor(ng, routed, 'routed');
+ const graph = graphFor(ng, routed, 'routed', source.tree);
if (graph) return graph;
}
let empty: SignalGraph | null = null;
- for (const host of componentHosts(ng, doc, MAX_FALLBACK_HOSTS)) {
- const graph = graphFor(ng, host, 'root');
+ for (const host of componentHosts(ng, source.tree, MAX_FALLBACK_HOSTS)) {
+ const graph = graphFor(ng, host, 'root', source.tree);
if (graph?.nodes.length) return graph;
empty ??= graph;
}
diff --git a/packages/ng-devtools/src/signal-history.ts b/packages/ng-devtools/src/signal-history.ts
index 04e4fff..91b8317 100644
--- a/packages/ng-devtools/src/signal-history.ts
+++ b/packages/ng-devtools/src/signal-history.ts
@@ -167,3 +167,38 @@ export function createSignalHistory(
return { onWrite, collect, collectDelta, changesOf };
}
+
+type SignalSetHook = ((node: RawSignalNode) => void) | null;
+
+export async function installSignalWriteHook(
+ onWrite: (node: RawSignalNode) => void,
+ load: () => Promise<{ setPostSignalSetFn: (fn: SignalSetHook) => SignalSetHook }> = () =>
+ import('@angular/core/primitives/signals') as never,
+): Promise<(() => void) | null> {
+ let setHook: (fn: SignalSetHook) => SignalSetHook;
+ try {
+ ({ setPostSignalSetFn: setHook } = await load());
+ } catch {
+ // Without the hook, history falls back to poll samples only.
+ return null;
+ }
+ if (typeof setHook !== 'function') return null;
+ let prev: SignalSetHook = null;
+ let active = true;
+ const hook = (node: RawSignalNode) => {
+ prev?.(node);
+ if (!active) return;
+ try {
+ onWrite(node);
+ } catch {
+ return;
+ }
+ };
+ prev = setHook(hook);
+ return () => {
+ active = false;
+ const current = setHook(prev);
+ // Someone chained after us; keep theirs, our hook now just forwards.
+ if (current !== hook) setHook(current);
+ };
+}