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); + }; +}