Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/contributing/coding-standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 0 additions & 25 deletions packages/ng-devtools/src/__tests__/dom-walk.test.ts

This file was deleted.

150 changes: 150 additions & 0 deletions packages/ng-devtools/src/__tests__/host-tree-views.test.ts
Original file line number Diff line number Diff line change
@@ -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<View> => ({
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<View, object>([
[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<View> = {
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<View>(
() => ({
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<View> = {
...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<View> = {
...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']);
});
});
86 changes: 86 additions & 0 deletions packages/ng-devtools/src/__tests__/host-tree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { angularRoots, domTree, type HostTree } from '../host-tree.ts';

function walk<H extends object>(tree: HostTree<H>): 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 =
'<a-root><b-shell><i-light></i-light></b-shell><e-tail></e-tail></a-root>';
const shell = document.querySelector('b-shell')!;
shell.attachShadow({ mode: 'open' }).innerHTML = '<c-child><d-leaf></d-leaf></c-child>';
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 = '<div><!--container--><span></span></div>';
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 =
'<div class="shell"><app-root ng-version="22"><app-inner ng-version="22"></app-inner></app-root></div><div class="overlay"></div>';
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 = '<main><p></p><p><b></b></p></main><x-host></x-host>';
const host = document.querySelector('x-host')!;
host.attachShadow({ mode: 'open' }).innerHTML = '<i></i>';
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 = '<p><!--a--></p>';
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);
});
});
3 changes: 2 additions & 1 deletion packages/ng-devtools/src/__tests__/ngrx-collector.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 };
}

Expand Down
Loading
Loading