diff --git a/app/src/__tests__/di-refresh-hints.test.ts b/app/src/__tests__/di-refresh-hints.test.ts new file mode 100644 index 00000000..97bd963a --- /dev/null +++ b/app/src/__tests__/di-refresh-hints.test.ts @@ -0,0 +1,260 @@ +import { type ComponentFixture, TestBed } from '@angular/core/testing'; +import type { DevframeRpcClient } from 'devframe/client'; +import { afterEach, describe, expect, it } from 'vitest'; +import { DiInspector } from '../pages/di-inspector'; + +const provider = (token: string) => ({ token, type: 'class', isViewProvider: false }); + +const element = (id: string, name: string, children: unknown[] = [], deps: string[] = []) => ({ + injector: { id, type: 'element', name, providerCount: 0, component: name }, + providers: [], + children, + dependencies: deps.map((token) => ({ from: name, token, flags: [], providedBy: 'inj-r' })), +}); + +const liveTree = { + pages: { + p1: { + pageId: 'p1', + reportedAt: 1, + roots: [element('inj-1', 'app-root', [element('inj-2', 'app-card', [], ['CartStore'])])], + environment: [ + { + injector: { id: 'inj-r', type: 'environment', name: 'Root', providerCount: 2 }, + providers: [provider('CartStore'), provider('Settings')], + children: [ + { + injector: { + id: 'inj-a', + type: 'environment', + name: 'Route: admin', + providerCount: 1, + inactive: true, + }, + providers: [provider('AdminApi')], + children: [], + }, + ], + }, + ], + }, + }, +}; + +const source = [ + { + token: 'CartStore', + source: 'class', + file: 'src/cart.ts', + line: 3, + providedIn: 'root', + type: 'injectable', + }, + { + token: 'Settings', + source: 'class', + file: 'src/settings.ts', + line: 1, + providedIn: 'root', + type: 'injectable', + }, + { + token: 'Unused', + source: 'class', + file: 'src/unused.ts', + line: 2, + providedIn: 'root', + type: 'injectable', + }, + { + token: 'PANEL_TITLE', + source: 'providers array', + file: 'src/app.ts', + line: 9, + type: 'provider', + }, + { + token: 'Router', + source: 'provideRouter()', + file: 'src/app.config.ts', + line: 4, + providedIn: 'root', + type: 'root-provider', + }, + { + token: 'provideFeature()', + source: 'provideFeature()', + file: 'src/app.config.ts', + line: 5, + type: 'helper', + }, + { token: 'CartStore', source: 'cart', file: 'src/card.ts', line: 7, type: 'injection' }, +]; + +const calls: { name: string; payload: unknown }[] = []; + +function client(tree: unknown): DevframeRpcClient { + const rpc = { + call: (name: string, payload: unknown) => { + calls.push({ name, payload }); + if (name === 'get-providers') return Promise.resolve(source); + if (name === 'request-injector-refresh') return Promise.resolve({ ok: true }); + return Promise.resolve([]); + }, + callEvent: () => Promise.resolve(), + sharedState: () => Promise.resolve({ value: () => tree, on: () => () => {} }), + }; + return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient; +} + +async function settle(fixture: ComponentFixture) { + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + } +} + +async function render(tree: unknown = liveTree) { + const fixture = TestBed.createComponent(DiInspector); + fixture.componentRef.setInput('rpc', client(tree)); + document.body.append(fixture.nativeElement); + await settle(fixture); + return fixture; +} + +const host = (fixture: ComponentFixture) => fixture.nativeElement as HTMLElement; +const refreshes = () => calls.filter((c) => c.name === 'request-injector-refresh'); + +describe('Injectors refresh, inactive route injectors and hints', () => { + afterEach(() => { + calls.length = 0; + localStorage.clear(); + document.body.innerHTML = ''; + }); + + it('asks the page to read the selected element again, once per selection', async () => { + const fixture = await render(); + // The first row is selected while nothing else is chosen. + expect(refreshes().map((c) => c.payload)).toEqual([{ pageId: 'p1', injectorId: 'inj-1' }]); + fixture.componentInstance.select('inj-2'); + await settle(fixture); + fixture.componentInstance.select('inj-2'); + await settle(fixture); + expect(refreshes().map((c) => c.payload)).toEqual([ + { pageId: 'p1', injectorId: 'inj-1' }, + { pageId: 'p1', injectorId: 'inj-2' }, + ]); + // An environment injector is never cached on its own, so selecting one asks for nothing. + fixture.componentInstance.setView('environment'); + await settle(fixture); + expect(refreshes()).toHaveLength(2); + }); + + it('reads every injector again on Refresh', async () => { + const fixture = await render(); + calls.length = 0; + const button = [...host(fixture).querySelectorAll('.toolbar button')].find( + (b) => b.textContent?.trim() === 'Refresh', + )!; + button.click(); + await settle(fixture); + expect(refreshes().map((c) => c.payload)).toEqual([{ pageId: 'p1' }]); + expect(host(fixture).querySelector('[role="status"]')?.textContent).toContain( + 'Reading the injectors again.', + ); + }); + + it('marks a lazy route injector off the rendered path as inactive', async () => { + const fixture = await render(); + fixture.componentInstance.setView('environment'); + await settle(fixture); + const admin = [...host(fixture).querySelectorAll('.row')].find((r) => + r.textContent?.includes('Route: admin'), + )!; + expect(admin.querySelector('.inactive-flag')?.textContent?.trim()).toBe('inactive'); + fixture.componentInstance.select('inj-a'); + await settle(fixture); + expect(host(fixture).querySelector('.inactive-note')?.textContent).toContain('Inactive'); + }); + + it('hints at source providers nothing on the page injected, collapsed', async () => { + const fixture = await render(); + const toggle = host(fixture).querySelector('.orphans-toggle')!; + expect(toggle.textContent).toContain('2 providers in source are never injected on this page'); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + const body = host(fixture).querySelector('#di-orphans')!; + expect(body.hidden).toBe(true); + toggle.click(); + await settle(fixture); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + expect(body.hidden).toBe(false); + // CartStore is injected; Settings was created by Root; the rest never were. + expect([...body.querySelectorAll('.token')].map((t) => t.textContent?.trim())).toEqual([ + 'Unused', + 'PANEL_TITLE', + ]); + }); + + it('lists unresolved provide*() helpers in source mode, collapsed until opened', async () => { + const fixture = await render({ pages: {} }); + const toggle = host(fixture).querySelector('.group-toggle')!; + expect(toggle.textContent).toContain('Helpers (unresolved)'); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + const list = host(fixture).querySelector('#di-helpers')!; + expect(list.hidden).toBe(true); + toggle.click(); + await settle(fixture); + expect(list.hidden).toBe(false); + expect(list.textContent).toContain('provideFeature()'); + expect(list.textContent).toContain('helper (unresolved)'); + }); + + it('names the caps the server sent with a truncated tree', async () => { + const tree = structuredClone(liveTree) as typeof liveTree & { + pages: { p1: { truncated?: boolean; limits?: unknown } }; + }; + tree.pages.p1.truncated = true; + tree.pages.p1.limits = { elements: 300, injectors: 400, depth: 50 }; + const fixture = await render(tree); + const notice = host(fixture).querySelector('.notice')!.textContent!.replace(/\s+/g, ' '); + expect(notice).toContain('at most 300 element injectors and 400 in all'); + expect(notice).toContain('more than 50 levels deep'); + }); + + it('links a live provider to where the source declares it', async () => { + const fixture = await render(); + fixture.componentInstance.setView('environment'); + await settle(fixture); + fixture.componentInstance.select('inj-r'); + await settle(fixture); + const cart = [...host(fixture).querySelectorAll('.providers .provider')].find( + (li) => li.querySelector('.token')?.textContent?.trim() === 'CartStore', + )!; + expect(cart.querySelector('.declared')?.textContent).toContain('src/cart.ts'); + }); + + it('draws how Angular resolves an injected token', async () => { + const tree = structuredClone(liveTree); + const card = tree.pages.p1.roots[0]!.children[0] as { injector: { path?: string[] } }; + card.injector.path = ['inj-2', 'inj-1', 'inj-r', 'inj-null']; + const fixture = await render(tree); + fixture.componentInstance.select('inj-2'); + await settle(fixture); + const toggle = host(fixture).querySelector('.resolve-toggle')!; + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + toggle.click(); + await settle(fixture); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + const steps = [...host(fixture).querySelectorAll('.resolve-graph li')].map((li) => [ + li.className, + li.querySelector('.resolve-state')?.textContent?.trim(), + ]); + // The card asks itself, then app-root, then Root, which provides CartStore. + expect(steps).toEqual([ + ['resolve-asked', 'asked, not here'], + ['resolve-asked', 'asked, not here'], + ['resolve-provides', 'provides it'], + ['resolve-not-asked', 'not asked'], + ]); + }); +}); diff --git a/app/src/__tests__/signal-source-diff.test.ts b/app/src/__tests__/signal-source-diff.test.ts new file mode 100644 index 00000000..2bc7e46d --- /dev/null +++ b/app/src/__tests__/signal-source-diff.test.ts @@ -0,0 +1,177 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import type { DevframeRpcClient } from 'devframe/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { jsonDiff, MAX_DIFF_ENTRIES } from '../pages/json-diff'; +import { SignalInspector } from '../pages/signal-inspector'; + +const at = (s: number) => new Date(2026, 0, 1, 10, 0, s).getTime(); + +const componentGraph = { + pageId: 'p1', + nodes: [ + { id: '1', kind: 'signal', label: 'cart', epoch: 2, value: { items: ['a', 'b'], total: 2 } }, + { id: '2', kind: 'computed', label: 'total', epoch: 2, value: 2 }, + { id: '3', kind: 'effect', label: 'persist', epoch: 1, runs: 4, runMs: 2.5, lastRunMs: 0.5 }, + ], + edges: [], + component: { id: 'c1', name: 'Cart', tag: 'app-cart', path: 'app-cart' }, + history: { + '1': [ + { epoch: 0, value: { items: ['a'], total: 1, coupon: 'X' }, at: at(1), source: 'initial' }, + { epoch: 2, value: { items: ['a', 'b'], total: 2 }, at: at(2), source: 'write' }, + ], + }, +}; + +const serviceGraph = { + pageId: 'p1', + nodes: [ + { + id: '11', + kind: 'signal', + label: 'trips', + epoch: 2, + value: 2, + locator: { id: 'inj-2', key: 'trips', owner: 'service', service: 'TripStore' }, + }, + ], + edges: [], + injector: { id: 'inj-2', name: 'Root' }, + history: { + '11': [ + { epoch: 0, value: 1, at: at(1), source: 'initial' }, + { epoch: 2, value: 2, at: at(2), source: 'write' }, + ], + }, +}; + +const source = [ + { name: 'cart', kind: 'signal', file: 'src/cart.ts', line: 4, component: 'app-cart' }, + { name: 'cart', kind: 'signal', file: 'src/other.ts', line: 9, component: 'app-other' }, + { name: 'total', kind: 'computed', file: 'src/a.ts', line: 1, component: 'app-a' }, + { name: 'total', kind: 'computed', file: 'src/b.ts', line: 2, component: 'app-b' }, +]; + +async function render( + graph: unknown, + call: (name: string, args?: unknown) => Promise = async (name) => + name === 'get-signals' ? source : [], +) { + const state = (value: unknown) => Promise.resolve({ value: () => value, on: () => () => {} }); + const rpc = { + call, + callEvent: () => Promise.resolve(), + sharedState: (name: string) => + state( + name === 'signal-graph' + ? { graph, pages: { p1: graph } } + : name === 'component-tree' + ? { pages: { p1: { pageId: 'p1', roots: [] } } } + : { pages: {} }, + ), + }; + const fixture = TestBed.createComponent(SignalInspector); + fixture.componentRef.setInput('rpc', { + connectionMeta: {}, + scope: () => ({ rpc }), + } as unknown as DevframeRpcClient); + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + } + return fixture; +} + +async function select(fixture: ComponentFixture, id: string) { + fixture.componentInstance.selectedId.set(id); + await fixture.whenStable(); + fixture.detectChanges(); +} + +const el = (fixture: ComponentFixture) => fixture.nativeElement as HTMLElement; + +describe('jsonDiff', () => { + it('lists added, removed and changed paths', () => { + expect( + jsonDiff( + { a: 1, list: [1, 2], gone: true, deep: { x: 1 } }, + { a: 2, list: [1], deep: { x: 1 }, 'new key': 0 }, + ), + ).toEqual([ + { path: 'a', kind: 'changed', before: 1, after: 2 }, + { path: 'list[1]', kind: 'removed', before: 2 }, + { path: 'gone', kind: 'removed', before: true }, + { path: '["new key"]', kind: 'added', after: 0 }, + ]); + }); + + it('caps the list and counts the rest', () => { + const before = Object.fromEntries(Array.from({ length: 80 }, (_, i) => [`k${i}`, i])); + const after = Object.fromEntries(Array.from({ length: 80 }, (_, i) => [`k${i}`, i + 1])); + const diff = jsonDiff(before, after); + expect(diff).toHaveLength(MAX_DIFF_ENTRIES + 1); + expect(diff.at(-1)).toEqual({ path: '…', kind: 'changed', after: '30 more changes' }); + }); +}); + +describe('Signals: source links, history diffs, effect runs and service restore', () => { + afterEach(() => TestBed.resetTestingModule()); + + it('links a live node to the one declaration in the shown component', async () => { + const fixture = await render(componentGraph); + await select(fixture, '1'); + const links = el(fixture).querySelectorAll('.source-matches app-source-link'); + expect(links).toHaveLength(1); + expect(el(fixture).querySelector('.source-matches')?.textContent).toContain('src/cart.ts'); + }); + + it('lists the candidates when the name matches several declarations', async () => { + const fixture = await render(componentGraph); + await select(fixture, '2'); + const text = el(fixture).querySelector('.source-matches')?.textContent ?? ''; + expect(text).toContain('2 declarations match this name'); + expect(text).toContain('src/a.ts'); + expect(text).toContain('src/b.ts'); + }); + + it('shows what changed between consecutive history entries', async () => { + const fixture = await render(componentGraph); + await select(fixture, '1'); + const diff = el(fixture).querySelector('details.diff')!; + expect(diff.querySelector('summary')?.textContent).toContain( + 'Changes from the entry before (3)', + ); + const rows = [...diff.querySelectorAll('li')].map((li) => [ + li.querySelector('.diff-kind')?.textContent?.trim(), + li.querySelector('.diff-path')?.textContent?.trim(), + ]); + expect(rows).toEqual([ + ['added', 'items[1]'], + ['changed', 'total'], + ['removed', 'coupon'], + ]); + }); + + it('shows effect run counts where the page reports them', async () => { + const fixture = await render(componentGraph); + await select(fixture, '3'); + expect( + el(fixture).querySelector('.detail-panel dl')?.textContent?.replace(/\s+/g, ' '), + ).toContain('Runs 4, 2.5 ms in all, last 0.5 ms'); + }); + + it('restores a service signal through its injector', async () => { + const call = vi.fn(async (name: string) => + name === 'request-signal-restore' ? { ok: true, key: 'trips' } : [], + ); + const fixture = await render(serviceGraph, call); + await select(fixture, '11'); + const button = el(fixture).querySelector('button.restore')!; + button.click(); + await fixture.whenStable(); + expect(call).toHaveBeenCalledWith('request-signal-restore', { + pageId: 'p1', + request: { component: 'inj-2', owner: 'service', node: '11', epoch: 0 }, + }); + }); +}); diff --git a/app/src/pages/di-inspector.ts b/app/src/pages/di-inspector.ts index 6a910884..816dd629 100644 --- a/app/src/pages/di-inspector.ts +++ b/app/src/pages/di-inspector.ts @@ -29,43 +29,14 @@ import type { ComponentFocus } from '../types/component-focus.types'; import type { InjectorFocus } from '../types/injector-focus.types'; import { storeFlag, storedFlag } from '../view-prefs'; import { StartupSection } from './startup-section'; +import type { + InjectorTreeLimits, + InjectorTreeNode, + ProviderInfo as PanelProviderInfo, +} from '@pangular-inspector/devtools/internal/types'; -interface ProviderInfo { - token: string; - /** The page's id for the token object; two tokens can share a name. Missing in older reports. */ - tokenId?: string; - type: string; - isViewProvider: boolean; - multi?: boolean; - importPath?: string[]; - framework?: boolean; -} - -interface DependencyInfo { - from: string; - token: string; - tokenId?: string; - flags: string[]; - providedBy: string | null; -} - -interface InjectorNode { - injector: { - id: string; - type: string; - name: string; - providerCount: number; - component?: string; - componentId?: string; - directives?: string[]; - selector?: string; - path?: string[]; - framework?: boolean; - }; - providers: ProviderInfo[]; - children: InjectorNode[]; - dependencies?: DependencyInfo[]; -} +type ProviderInfo = PanelProviderInfo; +type InjectorNode = InjectorTreeNode; interface SourceProvider { token: string; @@ -89,6 +60,7 @@ interface InjectorPage { roots?: unknown; environment?: unknown; truncated?: unknown; + limits?: unknown; zone?: unknown; reportedAt?: unknown; } @@ -100,6 +72,10 @@ interface InjectorTreeState extends InjectorPage { const NULL_ID = 'inj-null'; const HIDE_FRAMEWORK_PREF = 'injectors.hideFramework'; const MAX_PROVIDED_BY = 12; +/** The `type` the source scan gives a provide*() call it can't resolve to tokens. */ +const HELPER_TYPE = 'helper'; +/** Source entries that provide a token, which the orphaned providers hint checks. */ +const PROVIDING_TYPES = new Set(['provider', 'injectable']); /** How long typing must pause before the search result is announced. */ const SEARCH_ANNOUNCE_MS = 600; @@ -243,6 +219,17 @@ function isTree(value: unknown): value is InjectorNode[] { @if (zone(); as zone) { Change detection: {{ zone }} } + @if (truncated()) { @@ -253,9 +240,10 @@ function isTree(value: unknown): value is InjectorNode[] { The page reported only part of its injector tree. - It keeps at most 2000 element injectors and 2500 in all, and leaves out injectors nested - more than 1000 levels deep, so a lookup path or a provider can point to one that is not - listed. + It keeps at most {{ limits().elements }} element injectors and + {{ limits().injectors }} in all, and leaves out injectors nested more than + {{ limits().depth }} levels deep, so a lookup path or a provider can point to one that + is not listed.

} @@ -281,6 +269,46 @@ function isTree(value: unknown): value is InjectorNode[] { } + @if (orphaned().length) { +
+ +
+

+ Provided in your source, but nothing on this page has injected them so far. This is + not an error: a guard, an interceptor or code that has not run yet may still use them. +

+
    + @for (p of orphaned(); track p.token + p.file + p.line) { +
  • + {{ p.token }} + @if (p.providedIn) { + providedIn: {{ p.providedIn }} + } + +
  • + } +
+
+
+ } +
@if (!rows().length) {
@@ -343,6 +371,9 @@ function isTree(value: unknown): value is InjectorNode[] { > {{ kind(row.node) }} {{ label(row.node) }} + @if (row.node.injector.inactive) { + inactive + } @if (row.node.injector.component) { {{ row.node.injector.component }} } @else if (row.node.injector.directives?.length) { @@ -408,6 +439,12 @@ function isTree(value: unknown): value is InjectorNode[] { } + @if (sel.injector.inactive) { +

+ Inactive: the router loaded this route's injector, but no element on the page uses + it now. It keeps the services it created until the app is reloaded. +

+ }

Lookup path

Angular asks these injectors in order until one has the token.

@@ -492,6 +529,42 @@ function isTree(value: unknown): value is InjectorNode[] { } @else { not provided anywhere } + +
+
+ @if (resolvingKey() === depKey(dep, $index)) { +
    + @for (step of resolution(dep); track step.id + $index) { +
  1. + + @if (step.id === nullId) { + Null injector + } @else { + + } + {{ step.text }} +
  2. + } +
+ }
} @@ -543,6 +616,21 @@ function isTree(value: unknown): value is InjectorNode[] { @if (p.importPath?.length) {
via {{ p.importPath!.join(' › ') }}
} + @if (sourceOf(p.token); as places) { +
+ Declared in + @for (place of places.shown; track place.file + place.line) { + + } + @if (places.more) { + +{{ places.more }} more + } +
+ } @for (group of groupedProviders(); track group.type) {
-

- {{ group.label }} {{ group.items.length }} -

-
    + @if (group.type === helperType) { +

    + +

    +

    + provide*() calls the scan can't resolve to the tokens they register, such as your + own provider functions. +

    + } @else { +

    + {{ group.label }} {{ group.items.length }} +

    + } +
      @for (p of group.items; track p.token + p.file + p.line) {
    • @@ -667,10 +787,17 @@ function isTree(value: unknown): value is InjectorNode[] { @if (p.providedIn) { providedIn: {{ p.providedIn }} } + @if (p.type === helperType) { + helper (unresolved) + }
      - @if (p.source !== 'class' && p.source !== 'providers array') { + @if ( + p.source !== 'class' && + p.source !== 'providers array' && + p.type !== helperType + ) { as {{ p.source }} }
      @@ -1414,6 +1541,224 @@ function isTree(value: unknown): value is InjectorNode[] { stroke-width: 2; stroke-linecap: round; } + .resolve-toggle { + font-size: 12px; + } + .resolution[hidden] { + display: none; + } + .resolve-graph { + position: relative; + display: flex; + flex-direction: column; + gap: 6px; + margin: 8px 0 0; + padding: 0 0 0 4px; + list-style: none; + } + .resolve-graph li { + position: relative; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; + min-width: 0; + padding-left: 18px; + font-size: 12px; + } + .resolve-graph li::before { + content: ''; + position: absolute; + left: 4px; + top: -6px; + bottom: 50%; + width: 2px; + background: var(--border-strong); + } + .resolve-graph li:first-child::before { + display: none; + } + .resolve-dot { + position: absolute; + left: 0; + width: 10px; + height: 10px; + border: 2px solid var(--border-strong); + border-radius: 50%; + background: var(--surface); + } + .resolve-provides .resolve-dot { + border-color: var(--ok); + background: var(--ok); + } + .resolve-throws .resolve-dot { + border-color: var(--danger); + background: var(--danger); + } + .resolve-state { + color: var(--text-2); + } + .resolve-provides .resolve-state { + color: var(--ok-text); + font-weight: 600; + } + .resolve-throws .resolve-state { + color: var(--danger-text); + } + .resolve-not-asked .resolve-state, + .resolve-skipped .resolve-state { + color: var(--text-3); + } + .declared { + color: var(--text-3); + } + .decl-file { + min-width: 0; + font-family: var(--font-mono); + font-size: 11px; + overflow-wrap: anywhere; + } + .refresh { + display: inline-flex; + align-items: center; + gap: 6px; + height: var(--control-h); + margin-left: auto; + padding: 0 12px; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font-size: 12px; + cursor: pointer; + transition: + background-color 150ms var(--ease), + border-color 150ms var(--ease); + } + .refresh:hover { + background: var(--surface-3); + border-color: var(--accent-line); + } + .refresh[aria-disabled='true'] { + cursor: default; + opacity: 0.6; + } + .refresh:focus-visible { + @include m.focus-ring; + } + .refresh svg, + .chevron { + flex: none; + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + } + .chevron { + transition: transform 150ms var(--ease); + } + .chevron.open { + transform: rotate(90deg); + } + @media (prefers-reduced-motion: reduce) { + .chevron { + transition: none; + } + } + .inactive-flag { + flex: none; + } + .inactive-note { + margin: 0 0 12px; + } + .orphans { + margin: 0 0 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + } + .orphans-toggle, + .provider-group .group-toggle { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: var(--control-h); + padding: 0 12px; + border: 0; + border-radius: var(--radius-sm); + background: none; + color: var(--text-2); + font-size: 12px; + text-align: left; + cursor: pointer; + } + .provider-group .group-toggle { + width: auto; + height: auto; + min-height: 0; + padding: 2px 4px; + margin-left: -4px; + color: inherit; + font: inherit; + letter-spacing: inherit; + text-transform: inherit; + } + .orphans-toggle:hover, + .provider-group .group-toggle:hover { + color: var(--text); + } + .orphans-toggle:focus-visible, + .provider-group .group-toggle:focus-visible { + @include m.focus-ring(-2px); + } + .hint-tag { + padding: 0 6px; + border-radius: 5px; + @include m.tint(accent); + font-size: 10px; + line-height: 16px; + } + .orphans-body { + padding: 0 12px 12px; + } + .orphan-list { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; + } + .orphan-list li { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; + min-width: 0; + font-size: 12px; + } + .orphans-body .hint { + margin: 0 0 8px; + } + .orphan-list .orphan-file { + min-width: 0; + color: var(--text-3); + font-family: var(--font-mono); + font-size: 11px; + overflow-wrap: anywhere; + } + .helper-hint { + margin: 0 0 8px; + } + .provider-list[hidden], + .orphans-body[hidden], + .helper-hint[hidden] { + display: none; + } .source-providers { display: flex; flex-direction: column; @@ -1558,6 +1903,17 @@ export class DiInspector { return isTree(environment) ? environment : []; }); readonly truncated = computed(() => this.shownPage()?.truncated === true); + /** The caps the server names with `truncated`, or the ones it has always used. */ + readonly limits = computed(() => { + const limits = this.shownPage()?.limits as Partial | undefined; + const whole = (n: unknown, fallback: number) => + typeof n === 'number' && Number.isInteger(n) && n > 0 ? n : fallback; + return { + elements: whole(limits?.elements, 2000), + injectors: whole(limits?.injectors, 2500), + depth: whole(limits?.depth, 1000), + }; + }); readonly zone = computed(() => { const zone = this.shownPage()?.zone; return typeof zone === 'string' ? zoneLabel(zone) : null; @@ -1774,6 +2130,7 @@ export class DiInspector { { type: 'injectable', label: 'Injectable Services', items: [] }, { type: 'injection', label: 'inject() Calls', items: [] }, { type: 'provider', label: 'Component Providers', items: [] }, + { type: HELPER_TYPE, label: 'Helpers (unresolved)', items: [] }, ]; for (const p of filtered) groups.find((g) => g.type === p.type)?.items.push(p); return groups.filter((g) => g.items.length > 0); @@ -1783,6 +2140,118 @@ export class DiInspector { this.groupedProviders().reduce((sum, g) => sum + g.items.length, 0), ); + readonly helperType = HELPER_TYPE; + /** The unresolved provide*() helpers start collapsed; a search shows them. */ + readonly helpersOpen = signal(false); + readonly helpersShown = computed(() => this.helpersOpen() || !!this.normalizedQuery()); + + /** + * Providers the source declares (`providers` entries and injectables) whose + * token nothing on the shown page injected: no element injector and no + * created service lists it among its dependencies. A `providedIn` service + * that an environment injector already created counts as injected. Matched + * by name, so this is only a hint. + */ + readonly orphaned = computed(() => { + const roots = this.roots(); + if (!roots.length) return []; + const injected = new Set(); + const created = new Set(); + for (const node of this.index().map.values()) { + for (const dep of node.dependencies ?? []) injected.add(dep.token); + if (node.injector.type !== 'element') { + for (const p of node.providers) created.add(p.token); + } + } + const out: SourceProvider[] = []; + const seen = new Set(); + for (const p of this.sourceProviders()) { + if (!PROVIDING_TYPES.has(p.type)) continue; + // `Feature.Token` in source is the token `Token` on the page. + const name = p.token.slice(p.token.lastIndexOf('.') + 1); + if (injected.has(name) || (p.providedIn && created.has(name))) continue; + const key = `${p.token}|${p.file}|${p.line}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(p); + } + return out; + }); + readonly orphansOpen = signal(false); + + /** + * Where the source scan declares each token: `providers` entries, injectable + * classes and known provide*() functions, by token name (the last part of + * `Feature.Token`). Joins the live Provides list to the source. + */ + private readonly sourceByToken = computed(() => { + const map = new Map(); + for (const p of this.sourceProviders()) { + if (!PROVIDING_TYPES.has(p.type) && p.type !== 'root-provider') continue; + const name = p.token.slice(p.token.lastIndexOf('.') + 1); + let list = map.get(name); + if (!list) map.set(name, (list = [])); + if (!list.some((q) => q.file === p.file && q.line === p.line)) list.push(p); + } + return map; + }); + + /** Up to three source places that declare `token`, and how many more. */ + sourceOf(token: string): { shown: SourceProvider[]; more: number } | null { + const list = this.sourceByToken().get(token); + if (!list?.length) return null; + return { shown: list.slice(0, 3), more: Math.max(0, list.length - 3) }; + } + + /** The dependency whose resolution is open in the details. */ + readonly resolvingKey = signal(null); + + depKey(dep: { from: string; token: string; tokenId?: string }, index: number): string { + return `${index}|${dep.from}|${dep.tokenId ?? dep.token}`; + } + + toggleResolve(key: string) { + this.resolvingKey.update((open) => (open === key ? null : key)); + } + + /** + * The lookup path of the selected injector, read for one dependency: the + * steps Angular asked before the injector that provides the token, that + * injector, and the steps it never reached. `self` stops after the first + * step, `skipSelf` skips it, and a token nobody provides goes on to the null + * injector, which throws unless the dependency is optional. + */ + resolution(dep: { flags: string[]; providedBy: string | null }) { + const steps = this.path(); + const at = dep.providedBy ? steps.findIndex((step) => step.id === dep.providedBy) : -1; + const self = dep.flags.includes('self'); + const skipSelf = dep.flags.includes('skipSelf'); + const optional = dep.flags.includes('optional'); + return steps.map((step, index) => { + let state: 'asked' | 'provides' | 'skipped' | 'not-asked' | 'throws' | 'null'; + if (index === 0 && skipSelf) state = 'skipped'; + else if (at >= 0) state = index < at ? 'asked' : index === at ? 'provides' : 'not-asked'; + else if (self && index > (skipSelf ? 1 : 0)) state = 'not-asked'; + else if (step.id === NULL_ID) state = optional ? 'null' : 'throws'; + else state = 'asked'; + const text = { + asked: 'asked, not here', + provides: 'provides it', + skipped: 'skipped (skipSelf)', + 'not-asked': 'not asked', + throws: 'throws NullInjectorError', + null: 'gives null (optional)', + }[state]; + return { ...step, state, text }; + }); + } + readonly orphanedText = computed(() => { + const count = new Set(this.orphaned().map((p) => p.token)).size; + return count === 1 + ? '1 provider in source is never injected on this page' + : `${count} providers in source are never injected on this page`; + }); + /** What a screen reader hears about the search results. */ private readonly searchSummary = computed(() => { const query = this.query().trim(); @@ -1801,6 +2270,8 @@ export class DiInspector { private treeLoad = 0; private lastSearchMessage = ''; + /** The page and element injector the panel last asked the page to read again. */ + private refreshedKey: string | null = null; constructor() { // One summary once typing pauses, instead of a live region that reads every keystroke. @@ -1823,6 +2294,17 @@ export class DiInspector { void this.loadInjectorTree(client); void this.loadSourceProviders(client); }); + // Element injectors are read once and cached on the page, so the selected + // one is read again, once each time the selection moves to it. + effect(() => { + const sel = this.selected(); + const pageId = this.shownPageId(); + const id = sel?.injector.type === 'element' ? sel.injector.id : null; + const key = id && pageId ? `${pageId}|${id}` : null; + if (!key || key === this.refreshedKey) return; + this.refreshedKey = key; + untracked(() => this.requestRefresh(pageId!, id!)); + }); // Page titles come from the component tree, and are only needed for the Page picker. effect((onCleanup) => { const client = this.rpc(); @@ -1959,6 +2441,23 @@ export class DiInspector { ); } + /** Asks the shown page to read every injector again, element entries included. */ + refresh() { + const pageId = this.shownPageId(); + if (!pageId) return; + this.requestRefresh(pageId); + this.announcement.set('Reading the injectors again.'); + } + + private requestRefresh(pageId: string, injectorId?: string) { + const client = this.rpc(); + if (!client) return; + void client + .scope('pangular') + .rpc.call('request-injector-refresh', injectorId ? { pageId, injectorId } : { pageId }) + .catch(() => {}); + } + /** Clears the search from a no-match message and keeps focus on the search box. */ clearQuery() { this.query.set(''); diff --git a/app/src/pages/json-diff.ts b/app/src/pages/json-diff.ts new file mode 100644 index 00000000..9bc80a59 --- /dev/null +++ b/app/src/pages/json-diff.ts @@ -0,0 +1,76 @@ +/** One difference between two JSON values, at a path such as `items[2].name`. */ +export interface JsonDiffEntry { + path: string; + kind: 'added' | 'removed' | 'changed'; + before?: unknown; + after?: unknown; +} + +/** Most entries one diff lists; the rest are counted in a last `…` entry. */ +export const MAX_DIFF_ENTRIES = 50; +const MAX_DEPTH = 8; + +const isObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +function key(path: string, name: string): string { + return /^[A-Za-z_$][\w$]*$/.test(name) + ? path + ? `${path}.${name}` + : name + : `${path}[${JSON.stringify(name)}]`; +} + +function same(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false; + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } +} + +/** + * The differences between two JSON values (the serialized history entries + * of a signal), deepest first by path order: keys and items added, removed or + * changed. Objects and arrays are compared member by member; anything else + * as a whole. Past {@link MAX_DIFF_ENTRIES} entries the rest are summed up in + * one last entry with the path `…`. + */ +export function jsonDiff(before: unknown, after: unknown): JsonDiffEntry[] { + const out: JsonDiffEntry[] = []; + let more = 0; + const push = (entry: JsonDiffEntry) => { + if (out.length < MAX_DIFF_ENTRIES) out.push(entry); + else more++; + }; + const walk = (a: unknown, b: unknown, path: string, depth: number) => { + if (same(a, b)) return; + if (depth < MAX_DEPTH && Array.isArray(a) && Array.isArray(b)) { + const length = Math.max(a.length, b.length); + for (let i = 0; i < length; i++) { + const at = `${path}[${i}]`; + if (i >= a.length) push({ path: at, kind: 'added', after: b[i] }); + else if (i >= b.length) push({ path: at, kind: 'removed', before: a[i] }); + else walk(a[i], b[i], at, depth + 1); + } + return; + } + if (depth < MAX_DEPTH && isObject(a) && isObject(b)) { + for (const name of Object.keys(a)) { + const at = key(path, name); + if (!(name in b)) push({ path: at, kind: 'removed', before: a[name] }); + else walk(a[name], b[name], at, depth + 1); + } + for (const name of Object.keys(b)) { + if (!(name in a)) push({ path: key(path, name), kind: 'added', after: b[name] }); + } + return; + } + push({ path: path || '(value)', kind: 'changed', before: a, after: b }); + }; + walk(before, after, '', 0); + if (more) out.push({ path: '…', kind: 'changed', after: `${more} more changes` }); + return out; +} diff --git a/app/src/pages/signal-inspector.ts b/app/src/pages/signal-inspector.ts index 5fa07614..0811345a 100644 --- a/app/src/pages/signal-inspector.ts +++ b/app/src/pages/signal-inspector.ts @@ -28,19 +28,14 @@ import { SourceLink } from '../ui/source-link'; import type { GraphLink } from './signal-graph-layout'; import type { SignalFocus } from '../types/signal-focus.types'; import { SignalGraphView } from './signal-graph-view'; +import { jsonDiff, type JsonDiffEntry } from './json-diff'; +import type { + SignalChange, + SignalGraphElement, + SignalGraphNode, +} from '@pangular-inspector/devtools/internal/types'; -interface SignalNode { - id: string; - kind: string; - label?: string; - epoch: number; - value?: unknown; - changes?: number; - /** Created by Angular's own code, hidden unless the user shows framework signals. */ - framework?: boolean; - /** The component field that holds this writable signal, so history entries can be restored. */ - locator?: { id: string; key: string }; -} +type SignalNode = SignalGraphNode; interface RestoreResult { ok?: boolean; @@ -53,16 +48,6 @@ interface SignalEdge { producer: number; } -interface SignalChange { - epoch: number; - value: unknown; - at: number; - source: 'write' | 'sample' | 'initial'; - missed?: number; - /** First app frames of the code that set it, while write stacks are on. */ - stack?: string[]; -} - interface SignalResource { id: string; name: string; @@ -89,6 +74,7 @@ interface SignalGraph { componentSelector?: string; component?: { id: string; name: string; tag: string; path: string }; injector?: GraphInjector; + element?: SignalGraphElement; environments?: GraphInjector[]; resources?: SignalResource[]; nodeCount?: number; @@ -238,6 +224,14 @@ const KIND_TOKENS: Record = { {{ injectorLabel(injector) }} environment injector

      + } @else if (graph()?.element; as element) { +

      + {{ + element.directives.join(', ') || '<' + element.tag + '>' + }} + <{{ element.tag }}> + directives, no component +

      } @if (graph() && picked() && graph()!.source !== 'selected') {

      @@ -417,6 +411,29 @@ const KIND_TOKENS: Record = { }

      {{ change.value | json }}
      + @if (diffs().get(change.epoch); as diff) { +
      + Changes from the entry before ({{ diff.length }}) +
        + @for (d of diff; track d.path) { +
      • + {{ diffLabel(d.kind) }}: {{ d.path }}, + @if (d.kind !== 'added') { + {{ d.before | json }} + } + @if (d.kind === 'changed') { + to + } + @if (d.kind !== 'removed') { + {{ d.after | json }} + } +
      • + } +
      +
      + } @if (change.stack?.length) {
        @for (frame of change.stack; track $index) { @@ -753,6 +770,35 @@ const KIND_TOKENS: Record = {
        {{ selectedNode()!.kind }}
        Epoch
        {{ selectedNode()!.epoch }}
        + @if (selectedNode()!.runs; as runs) { +
        Runs
        +
        + {{ runs }}, {{ selectedNode()!.runMs ?? 0 }} ms in all, last + {{ selectedNode()!.lastRunMs ?? 0 }} ms +
        + } + @if (sourceMatch(); as match) { +
        Source
        +
        + @if (match.exact) { + + } @else { + {{ match.candidates.length }} declarations match this name: +
          + @for (c of match.candidates; track c.file + c.line) { +
        • + + @if (c.component) { + in {{ c.component }} + } +
        • + } +
        + } +
        + } @if (selectedNode()!.value !== undefined) {
        Value
        @@ -1493,6 +1539,72 @@ const KIND_TOKENS: Record = { .restore:focus-visible { @include m.focus-ring(-2px); } + .diff { + margin-top: 6px; + font-size: 12px; + } + .diff summary { + color: var(--text-2); + cursor: pointer; + } + .diff summary:focus-visible { + @include m.focus-ring; + } + .diff-list { + display: flex; + flex-direction: column; + gap: 2px; + margin: 6px 0 0; + padding: 0; + list-style: none; + } + .diff-list li { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 8px; + min-width: 0; + font-family: var(--font-mono); + font-size: 11px; + overflow-wrap: anywhere; + } + .diff-kind { + padding: 0 6px; + border-radius: 5px; + font-size: 10px; + line-height: 16px; + } + .diff-added .diff-kind { + @include m.tint(ok); + } + .diff-removed .diff-kind { + @include m.tint(danger); + } + .diff-changed .diff-kind { + @include m.tint(accent); + } + .diff-path { + color: var(--text-strong); + } + .diff-old { + color: var(--text-2); + text-decoration: line-through; + } + .diff-new { + color: var(--text); + } + .source-matches { + min-width: 0; + } + .source-candidates { + margin: 4px 0 0; + padding: 0; + list-style: none; + } + .hint-inline { + color: var(--text-2); + font-size: 12px; + } .restore-result { margin: 0; font-size: 12px; @@ -1765,7 +1877,9 @@ export class SignalInspector { }); private readonly graphOwner = computed(() => { const g = this.graph(); - return g ? `${g.pageId ?? ''}|${g.component?.id ?? g.injector?.id ?? ''}` : null; + return g + ? `${g.pageId ?? ''}|${g.component?.id ?? g.injector?.id ?? g.element?.id ?? ''}` + : null; }); // Component and injector ids belong to one page, so each page keeps its own pick. @@ -1914,7 +2028,11 @@ export class SignalInspector { restorable(id: string): SignalNode | null { const g = this.graph(); const node = g?.nodes.find((n) => n.id === id); - return node?.locator && g?.component && node.locator.id === g.component.id ? node : null; + if (!node?.locator) return null; + if (node.locator.owner === 'service') { + return g?.injector && node.locator.id === g.injector.id ? node : null; + } + return g?.component && node.locator.id === g.component.id ? node : null; } /** Writes the value the page kept for this entry back to the signal. */ @@ -1928,7 +2046,12 @@ export class SignalInspector { try { result = ((await client.scope('pangular').rpc.call('request-signal-restore', { pageId, - request: { component: locator.id, node: node.id, epoch: change.epoch }, + request: { + component: locator.id, + ...(locator.owner === 'service' ? { owner: 'service' } : {}), + node: node.id, + epoch: change.epoch, + }, })) ?? {}) as RestoreResult; } catch { result = { error: 'The devtools server did not answer. Try again.' }; @@ -1945,6 +2068,60 @@ export class SignalInspector { ); } + /** + * What changed in each history entry of the selected node against the + * entry before it, by epoch. Only for entries whose values are both + * objects or arrays; a plain value already shows whole. + */ + readonly diffs = computed(() => { + const id = this.selectedId(); + const entries = id ? (this.graph()?.history?.[id] ?? []) : []; + const out = new Map(); + for (let i = 1; i < entries.length; i++) { + const before = entries[i - 1]!.value; + const after = entries[i]!.value; + if (!before || !after || typeof before !== 'object' || typeof after !== 'object') continue; + const diff = jsonDiff(before, after); + if (diff.length) out.set(entries[i]!.epoch, diff); + } + return out; + }); + + diffLabel(kind: JsonDiffEntry['kind']): string { + return kind === 'added' ? 'added' : kind === 'removed' ? 'removed' : 'changed'; + } + + /** + * The source declaration of the selected live node, from the source scan: + * declarations with the node's name and a matching kind, narrowed to the + * shown component's selector when some are in it. One left is `exact`; + * several are listed as `candidates`, since the name alone can't tell them + * apart. + */ + readonly sourceMatch = computed<{ + exact: SourceSignal | null; + candidates: SourceSignal[]; + } | null>(() => { + const node = this.selectedNode(); + const label = node?.label; + if (!node || !label) return null; + const kindMatches = (source: string) => + source === node.kind || + source.startsWith(`${node.kind} `) || + source.startsWith(`${node.kind}.`) || + (node.kind === 'signal' && source.startsWith('model')) || + (node.kind === 'computed' && source === 'toSignal'); + let found = this.sourceSignals().filter((s) => s.name === label && kindMatches(s.kind)); + const g = this.graph(); + const owner = g?.component?.tag ?? g?.element?.tag; + const own = owner ? found.filter((s) => s.component === owner) : []; + if (own.length) found = own; + if (!found.length) return null; + return found.length === 1 + ? { exact: found[0]!, candidates: [] } + : { exact: null, candidates: found.slice(0, 5) }; + }); + /** Why a writable signal has no exact set entries, when the panel can tell. */ readonly writeHint = computed(() => { const node = this.selectedNode(); @@ -1952,7 +2129,7 @@ export class SignalInspector { if (!node || !g || g.writeHook === false) return ''; if (node.kind !== 'signal' && node.kind !== 'linkedSignal') return ''; if (!node.label) { - return "This signal has no name, so its history holds sampled values only. Pass a debugName in its options, for example signal(0, { debugName: 'count' })."; + return "This signal has no name, so its history holds sampled values only. From Angular 20.1 the compiler names a signal that initializes a field or variable, so this one was likely made in a factory or helper. Pass a debugName in its options, for example signal(0, { debugName: 'count' })."; } const history = g.history?.[node.id] ?? []; if (history.some((c) => c.source === 'write') || !history.some((c) => c.source === 'sample')) { @@ -2023,8 +2200,7 @@ export class SignalInspector { const flat: LiveNode[] = []; const walk = (nodes: LiveNode[]) => { for (const node of nodes) { - // A directive host has no component signals to read. - if (node.kind !== 'directive') flat.push(node); + flat.push(node); walk(node.children); } }; @@ -2048,7 +2224,7 @@ export class SignalInspector { options.push({ value: node.id, label: (totals.get(node.name) ?? 0) > 1 ? `${node.name} #${n}` : node.name, - hint: `<${node.tag}>`, + hint: node.kind === 'directive' ? `<${node.tag}> directive` : `<${node.tag}>`, }); } return options; diff --git a/app/tsconfig.json b/app/tsconfig.json index 6f564dc9..f1da4410 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -8,6 +8,7 @@ "isolatedModules": true, "experimentalDecorators": true, "importHelpers": true, + "rewriteRelativeImportExtensions": true, "types": [], "lib": ["ES2022", "DOM", "DOM.Iterable"], "paths": {} diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index 6ab0993a..bd174e32 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -195,7 +195,7 @@ These seven tools read your source and answer compact JSON. Every tool that read | `scan-routes` | Angular routes from your route files, with full URL path (parents and `loadChildren` prefixes included), kind (page, group, redirect or wildcard), guards, resolvers, and file and line. | None | | `scan-components` | Components and directives from `@Component` and `@Directive` classes, with class name, selector, kind, inputs, outputs, change detection (components only), and file and line. | `filter`, `offset`, `limit` | | `scan-signals` | `signal()`, `computed()`, `linkedSignal()`, `effect()`, `toSignal()` and resource declarations (`resource`, `httpResource`, `rxResource`), plus signal inputs, models and queries. | `filter`, `offset`, `limit` | -| `scan-providers` | DI providers: `@Injectable` services, `inject()` calls, constructor parameters and `providers` arrays, with token, file and where each one is provided. | None | +| `scan-providers` | DI providers: `@Injectable` services, `inject()` calls, constructor parameters and `providers` arrays, with token, file and where each one is provided. Other `provide*()` calls have `type: "helper"`. | None | | `ngrx-scan` | NgRx declarations: `@ngrx/store` actions (with their type strings in `types`), reducers, effects, selectors, features and store setup, and `@ngrx/signals` `signalStore` (with its members), `signalState` and `signalMethod`. | None | | `scan-pipes` | Custom `@Pipe` classes, and built-in pipes from `@angular/common` in use in templates, with purity, standalone status, and where each is declared or used. | None | | `scan-build` | The project name, the Angular and TypeScript versions, SSR status, the Analog version in Analog apps, and a `builtAt` timestamp. | None | @@ -468,28 +468,31 @@ An [Angular Native](../getting-started/angular-native.md) or [NativeScript](../g The signal graph the page reported: nodes (`signal`, `computed`, `linkedSignal`, `effect`), dependency edges (`{ consumer, producer }`, where `consumer` reads `producer`, both node ids), the component or injector they belong to, and recent value history per node. Reads: page. -| Argument | Required | Value | -| ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `component` | one of | Host tag, class name or instance id of the component, like `app-root`. Or `root`, or a route path like `/admin` or `Route: admin`. | -| `selector` | one of | A CSS selector for a component host, like `app-card.featured`. The first match is read. At most 500 characters. | -| `node` | no | A node id or label. The answer holds only that node, or every node with that label, with its direct producers and consumers and their resources and history. | -| `history` | no | `false` leaves out the value history. Defaults to `true`. | -| `framework` | no | `false` leaves out the nodes Angular itself created (`framework: true`). Defaults to `true`. | +| Argument | Required | Value | +| ----------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `component` | one of | Host tag, class name or instance id of the component, like `app-root`. Also accepts `root`, a route path like `/admin` or `Route: admin`, an element injector id like `inj-12` from `inspect-providers`, or a directive class name. A host that has directives but no component reads as a directive host. | +| `selector` | one of | A CSS selector for a component host, like `app-card.featured`. The first match is read. At most 500 characters. | +| `node` | no | A node id or label. The answer holds only that node, or every node with that label, with its direct producers and consumers and their resources and history. | +| `history` | no | `false` leaves out the value history. Defaults to `true`. | +| `framework` | no | `false` leaves out the nodes Angular itself created (`framework: true`). Defaults to `true`. | Pass `component` or `selector`, not both. When the graph the page last reported belongs to another component or injector, the tool asks the page for the one you named, once, and the Signals page keeps showing what it showed. A `component` the component tree knows is read by its first instance. A `component` it doesn't know answers with the graph the page reported and says what that graph covers. A `selector` that matches no component host is refused. The answer also holds: -| Field | Value | -| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `resources` | One entry per `resource()`, `httpResource()` or `rxResource()`: `status`, `isLoading`, `params`, `value`, `error`, `nodeIds`. | -| `environments` | The root and route injectors the page can report, with `id` and `name`. | -| `changes` | On a node or resource, every change since the page first saw it. The history keeps the last 50. | -| `nodeCount` | Set when Angular reported more than the 400 nodes the page keeps. | -| `unnamedSignals` | Set when signals, computeds or linkedSignals in the graph have no `debugName`. Their history holds sampled values only. | -| `framework` | `true` on a node that Angular's router, forms or HTTP code created. See [Framework signals](../inspectors/signals.md#framework-signals). | -| `watch` | The node ids the page watches (`ids`) and whether write stacks are on (`stacks`). Set only while one of them is on. | -| `stack` | On a `write` history entry while write stacks are on: the first 3 app frames of the code that set the signal. | +| Field | Value | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `resources` | One entry per `resource()`, `httpResource()` or `rxResource()`: `status`, `isLoading`, `params`, `value`, `error`, `nodeIds`. | +| `environments` | The root and route injectors the page can report, with `id` and `name`. | +| `changes` | On a node or resource, every change since the page first saw it. The history keeps the last 50. | +| `nodeCount` | Set when Angular reported more than the 400 nodes the page keeps. | +| `unnamedSignals` | Set when signals, computeds or linkedSignals in the graph have no `debugName`. Their history holds sampled values only. | +| `framework` | `true` on a node that Angular's router, forms or HTTP code created. See [Framework signals](../inspectors/signals.md#framework-signals). | +| `watch` | The node ids the page watches (`ids`) and whether write stacks are on (`stacks`). Set only while one of them is on. | +| `stack` | On a `write` history entry while write stacks are on: the first 3 app frames of the code that set the signal. | +| `element` | The element injector the graph was read from: `id`, `tag`, `directives`, and `injectorId` when you passed an element injector id. Without `component`, the host has directives only. | +| `locator` | On a writable node the page can restore: `id` and `key` of the component field, or `owner: "service"` with the injector id and the service class in `service`. | +| `runs`, `runMs`, `lastRunMs` | On an effect, where Angular's profiler reports effect runs: how many times it ran, and its total and last run time in ms. See [Effect runs](../inspectors/signals.md#effect-runs). | The answer is cut at 20,000 characters. A graph that is too long first keeps fewer history entries per node, then keeps the first nodes that fit. A note under the JSON says how many history entries and nodes it left out. Pass `node` or `history: false` to see the rest. @@ -525,7 +528,7 @@ The injectors a page reported. Element injectors list what each component inject An `injector` that names an element injector returns it with its lookup path resolved to names and provided tokens. One that names an environment injector, in any case, returns that injector's providers, what the services it already created inject, and its parent injectors. When nothing matches, the answer lists the tags and environment injector names on the page to retry with. -An element injector that belongs to a component carries `componentId`, the instance id that `inspect-component`, `list-components` and the `component-tree` resource use. `framework: true` marks the injectors and providers that come from the installed `@angular/*` packages; see [Framework injectors](../inspectors/injectors.md#framework-injectors). +An element injector that belongs to a component carries `componentId`, the instance id that `inspect-component`, `list-components` and the `component-tree` resource use. `framework: true` marks the injectors and providers that come from the installed `@angular/*` packages; see [Framework injectors](../inspectors/injectors.md#framework-injectors). `inactive: true` marks the injector of a lazy route the router loaded that no rendered element uses, such as a route the app left; see [View switch](../inspectors/injectors.md#view-switch). Without `injector` or `token`, the answer is the whole tree and says which change detection mode the page runs. The answer stays within 20,000 characters and its JSON always parses. A larger tree shows element injectors as an outline (id, name, type, component, and provider and dependency counts), and when that is still too long it leaves out the deepest injectors and says how many. The environment injectors are always there, as an outline when they are large, and without their deepest injectors when even that outline is too long. An `injector` or `token` answer that is too long cuts each injector's `providers` and `dependencies` lists, with `providersTotal` and `dependenciesTotal` giving the full counts, so a large injector such as `Root` still shows. An `@ngrx/signals` store that an environment injector created carries the name the NgRx tools use, such as `TravelStore`, in `token`, and its class name `SignalStore` in `tokenClass`; `token` finds it by either name, and so do `read-provider-value` and `explain-injection`. `injector` and `token` take at most 500 characters each. diff --git a/apps/docs/src/content/inspectors/injectors.md b/apps/docs/src/content/inspectors/injectors.md index 3a403508..cf0b1b51 100644 --- a/apps/docs/src/content/inspectors/injectors.md +++ b/apps/docs/src/content/inspectors/injectors.md @@ -24,14 +24,26 @@ A switch at the top picks the view: Each row shows a kind letter (`C`, `D` or `E`), the tag or injector name, the component or directive classes, and icons with the number of injected and provided tokens. +The Environment view also lists the injectors of lazy routes the router already loaded but that no rendered element uses, such as a route the app navigated away from. They carry an **inactive** chip, and their details say the router loaded them but no element on the page uses them now. They keep the services they created until the app reloads. + The toolbar also shows the change detection mode the page reported: **Zoneless**, **zone.js**, or **Zoneless, zone.js loaded** when zone.js is on the page but the app doesn't use it. +### Refresh + +The overlay reads each element injector once, because reading what it injects is costly. Selecting an element injector reads that one anew. **Refresh** in the toolbar reads every injector. Use it when a component injects something after it was first listed, as a lazy `inject()` call does. + ### Page picker When more than one open page reports an injector tree, a **Page** picker appears in the toolbar. It lists each page by title, or by URL when the page has no title. The tab starts on the page that reported last, and stays on the page you choose when another page reports later. Each page keeps its own selected injector and expanded rows, and hovering a row highlights the element in the chosen page. If the chosen page closes, the tab moves to the page that reported last and announces the change to screen readers. When the panel opens framed by a page, such as the popup on that page, the tab shows that page only and has no **Page** picker. +### Providers never injected + +When a live tree is shown, a collapsed **Hint** line above the tree counts the providers your source declares that nothing on the shown page injected, for example **2 providers in source are never injected on this page**. Open it to see each token with its file, line and `providedIn`. + +It compares `providers` entries and injectable services from the [source scan](#source-mode) with the tokens that element injectors and created services injected, by name. A `providedIn` service that an environment injector already created counts as injected. It is a hint, never an error: a guard, an interceptor or code that has not run yet can still inject a provider it lists. + ### Search and filters - Search for a token, component, directive or injector. When a token matches, **Provided by** chips list the injectors that provide it. Click one to jump there. The row shows up to 12 chips, then **+N more**. Once typing pauses, a screen reader announces how many injectors provide the token, or that nothing matches. @@ -69,6 +81,8 @@ Select an injector to see the **Lookup path**: the injectors Angular asks, in or For element injectors, **Injected here** lists each token requested at this level and the injector that answered. The block marks a token that nobody provides as **not provided anywhere**, and an optional token that nobody provides as **optional, not provided**. A provider with a `null` value, such as `useValue: null`, still counts as provided. When the element has more than one class, each row says which class asked. +Each row has a **Resolution** button. It draws the lookup path for that token as a chain of steps: the injectors Angular asked and that did not have it (**asked, not here**), the one that **provides it**, and the ones it never reached (**not asked**). A `skipSelf` token marks the first step **skipped (skipSelf)**. A token nobody provides ends at the null injector, which **throws NullInjectorError**, or **gives null (optional)** for an optional token. Click a step to open that injector. + ### Injected by its services For environment injectors, **Injected by its services** lists what the services this injector created inject, and which injector answered. Each row says which service asked. `InjectionToken` providers built by a factory, such as `useFactory`, are listed too, with the token as the one that asked. Services that Angular has not created yet are left out, because reading their dependencies would create them. A `providedIn: 'root'` service shows on the root injector once something injects it. @@ -77,6 +91,8 @@ For environment injectors, **Injected by its services** lists what the services **Provides** lists each provider with its kind: `useClass`, `useValue`, `useFactory` or `useExisting`. A bare class shows as `useClass`. Chips mark **viewProviders** and **multi** providers. Providers that come from imported modules show the import path, as `via A › B`. +When the [source scan](#source-mode) declares the token, the row says **Declared in** with links to up to three places, such as the `@Injectable` class or the `providers` entry, and how many more there are. The match is by token name. + ### Peek at a provider value Each live provider row has a **Show value** button. It reads the provider's current value from the page and lists it like the properties of a component: its own fields, with signals and resources unwrapped (marked **signal** or **resource**), and methods left out. An object, array, Map or Set opens one level at a time, and each level is read from the page when you open it. A value without fields, such as a string or an array from `useValue`, shows whole. **Refresh** reads it again, since the list does not follow the app on its own. When several providers share a token, as `multi` providers do, their buttons and values are named with their position, such as **(2 of 2)**. @@ -102,7 +118,7 @@ A class instance is stored as the live object. A `useValue` object or array is s ### Source mode -Without a live tree, the tab lists DI found in your source files, in four groups: +Without a live tree, the tab lists DI found in your source files, in five groups: | Group | Lists | | ------------------------------ | ------------------------------------------------------------------------------------------------------------- | @@ -110,6 +126,7 @@ Without a live tree, the tab lists DI found in your source files, in four groups | **Injectable Services** | `@Injectable` and `@Service` classes, plus `signalStore` and `InjectionToken` declarations with `providedIn`. | | **inject() Calls** | `inject(T)` calls and constructor parameters of decorated classes, typed or with `@Inject(T)`. | | **Component Providers** | Any `providers` or `viewProviders` array, in components, routes, app config or NgModules. | +| **Helpers (unresolved)** | Calls to other `provide*()` functions, such as your own, marked **helper (unresolved)**. Collapsed at first. | If the devtools server does not answer, the tab says **Couldn't load DI data** with a **Retry** button instead of an empty list. If only the live tree fails, the source list stays and a **Couldn't load the live injector tree** message with **Retry** sits above it. @@ -245,7 +262,7 @@ The full tree needs Angular 17.0 or later. On older versions the tab shows a not ### Up to 2000 element injectors -The page reports at most 2000 element injectors. Environment injectors are kept after them, up to 2500 injectors in all, so they never push element injectors out. Injectors nested more than 1000 levels deep are left out too. When any of these limits cuts the tree, a notice above the tree says the page reported only part of its injector tree, and a lookup path or a provider can point to an injector that isn't listed. `inspect-providers` says it too. +The page reports at most 2000 element injectors. Environment injectors are kept after them, up to 2500 injectors in all, so they never push element injectors out. Injectors nested more than 1000 levels deep are left out too. When any of these limits cuts the tree, a notice above the tree says the page reported only part of its injector tree, with the limits the server applied, and a lookup path or a provider can point to an injector that isn't listed. The server sends those limits with the tree as `limits`. `inspect-providers` says it too. ### Some injectors can't be highlighted @@ -253,7 +270,15 @@ Hover highlights need a CSS selector for the element. Elements inside a shadow r ### Source mode only knows some provide functions -The **Root Providers** group matches a fixed list of Angular `provide*()` functions. It doesn't list your own provider functions. +The **Root Providers** group matches a fixed list of Angular `provide*()` functions. Any other `provide*()` call, such as your own `provideFeature()`, is listed under **Helpers (unresolved)** with the function's name, because the scan can't tell which tokens it registers. A method or member call named `provide*` is not listed. + +### Element injectors don't follow the page on their own + +Element injectors are read once, and again when you select one or press **Refresh**. A list can miss what a component injected after it was first read until then. Environment injectors are read on every update. + +### Inactive route injectors need the router's own fields + +Inactive route injectors are found through the `_injector` and `_loadedInjector` fields the router sets on its route objects, and through `ng.ɵgetRouterInstance` or the root injector's `Router` record. A Router that the app created somewhere else, or a future Angular that renames those fields, leaves them out. ## FAQ diff --git a/apps/docs/src/content/inspectors/signals.md b/apps/docs/src/content/inspectors/signals.md index b0c64141..0d3e5103 100644 --- a/apps/docs/src/content/inspectors/signals.md +++ b/apps/docs/src/content/inspectors/signals.md @@ -30,6 +30,7 @@ The **Graph of** picker at the top selects whose graph you see. It appears when - **Route: path** entries show the effects registered on the injector of a route with `providers`, on the active route chain. A route with an empty `path` is named by the path of its parent routes, such as **Route: review**, or **Route: /** at the top. Other environment injectors on that chain, such as the one of a lazy `loadChildren`, show as **Environment**. - Pick any live component to pin the graph to it. The picker numbers duplicates, for example `#2`. - **Show in Signals** in the [Components tab](./components.md#detail-header) pins the graph to the selected instance the same way. From a signal property, it also selects that signal's node. A `#tab=signals&id=&page=` [deep link](../getting-started/popup-and-hub.md#link-to-an-item) does the same. +- A host with directives but no component, such as ``; + const button = document.querySelector('button')!; + const ng: SignalDebugNg = { + ...fakeNg(), + getDirectives: (el) => (el === button ? [new Tooltip()] : []), + ɵgetSignalGraph: (injector) => + injector === (button as unknown) + ? { nodes: [{ id: '20', kind: 'effect', label: 'position', epoch: 1 }], edges: [] } + : { nodes: [], edges: [] }, + }; + return { ng, button }; + } + + it('reads the graph of a host with directives but no component', () => { + const { ng, button } = directivePage(); + const graph = collectSignalGraph(ng, { id: elementId(button) }); + expect(graph?.component).toBeUndefined(); + expect(graph?.element).toEqual({ + id: elementId(button), + tag: 'button', + directives: ['Tooltip'], + }); + expect(graph?.nodes).toEqual([expect.objectContaining({ id: '20', label: 'position' })]); + }); + + it('answers a peek by element injector id with that element graph', async () => { + const { ng, button } = directivePage(); + // The injector tree hands out the element injector id. + const { collectInjectorTree } = await import('../injector-tree.ts'); + const tree = collectInjectorTree({ + ...ng, + ɵgetInjectorProviders: () => [], + } as never); + const injectorId = JSON.stringify(tree).match( + /"id":"(inj-\d+)","type":"element","name":"button"/, + )![1]!; + const session = fakeRpc(); + registerSignalPeek(session.rpc, 'p1', (target: SignalTarget) => collectSignalGraph(ng, target)); + session.handlers.get(PEEK_EVENT)!({ requestId: 'q1', env: injectorId }); + await Promise.resolve(); + const [, answer] = session.calls.find(([name]) => name === PEEK_RESULT)!; + expect(answer).toMatchObject({ + requestId: 'q1', + graph: { element: { id: elementId(button), injectorId }, nodes: [{ id: '20' }] }, + }); + // An unknown element injector id gets no graph rather than a fallback. + session.handlers.get(PEEK_EVENT)!({ requestId: 'q2', env: 'inj-99999' }); + await Promise.resolve(); + expect( + session.calls.find(([, a]) => (a as { requestId: string }).requestId === 'q2')![1], + ).toMatchObject({ + graph: null, + }); }); }); diff --git a/packages/devtools/src/__tests__/signal-restore-rpc.test.ts b/packages/devtools/src/__tests__/signal-restore-rpc.test.ts index a35a5707..8b8afd6b 100644 --- a/packages/devtools/src/__tests__/signal-restore-rpc.test.ts +++ b/packages/devtools/src/__tests__/signal-restore-rpc.test.ts @@ -134,4 +134,39 @@ describe('request-signal-restore', () => { expect(await answer).toEqual({ error: expect.stringContaining('No page answered') }); expect(restores.pendingCount()).toBe(0); }); + + it('sends a service restore only for a node with a service locator', async () => { + const broadcast = vi.fn(); + const serviceGraph = { + pageId: 'p1', + nodes: [ + { + id: '11', + kind: 'signal', + label: 'trips', + epoch: 2, + locator: { id: 'inj-2', key: 'trips', owner: 'service', service: 'TripStore' }, + }, + ], + edges: [], + injector: { id: 'inj-2', name: 'Root' }, + }; + const restores = createSignalRestores({ + config: resolvePangularConfig(), + graphOf: () => serviceGraph as never, + broadcast, + answersFor: () => true, + timeoutMs: 20, + }); + // Without `owner`, a service node is refused. + expect(await restores.request('p1', { component: 'inj-2', node: '11', epoch: 1 })).toEqual({ + error: expect.stringContaining('shown component'), + }); + void restores.request('p1', { component: 'inj-2', owner: 'service', node: '11', epoch: 1 }); + expect(broadcast).toHaveBeenCalledWith('signal-restore', { + requestId: expect.any(String), + pageId: 'p1', + request: { component: 'inj-2', owner: 'service', node: '11', epoch: 1 }, + }); + }); }); diff --git a/packages/devtools/src/__tests__/signal-restore-service-real.test.ts b/packages/devtools/src/__tests__/signal-restore-service-real.test.ts new file mode 100644 index 00000000..9cf8e2b9 --- /dev/null +++ b/packages/devtools/src/__tests__/signal-restore-service-real.test.ts @@ -0,0 +1,115 @@ +// @vitest-environment jsdom +import '@angular/compiler'; +import { + Component, + effect, + inject, + Injectable, + provideZonelessChangeDetection, + signal, + type ApplicationRef, +} from '@angular/core'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { elementById } from '../element-id.ts'; +import { serializeNamed } from '../serialize.ts'; +import { collectSignalGraph, graphValue, type SignalDebugNg } from '../signal-graph.ts'; +import { createSignalHistory, installSignalWriteHook } from '../signal-history.ts'; +import { restoreSignalEntry } from '../signal-restore.ts'; +import type { SignalGraph } from '../types.ts'; +import { angularAtLeast, STANDALONE } from './angular-version.ts'; + +class TripStore { + readonly trips = signal(1, { debugName: 'trips' }); + readonly log: number[] = []; + constructor() { + effect(() => this.log.push(this.trips()), { debugName: 'persistTrips' }); + } +} +Injectable({ providedIn: 'root' })(TripStore); + +class App { + readonly store = inject(TripStore); +} +Component({ ...STANDALONE, selector: 'app-root', template: `{{ store.trips() }}` })(App); + +const settle = async (app: ApplicationRef) => { + for (let i = 0; i < 4; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + app.tick(); + } +}; + +describe.skipIf(!angularAtLeast('20.1.0'))('restoring a service signal from history', () => { + let app: ApplicationRef | null = null; + let unhook: (() => void) | null = null; + let ng: SignalDebugNg; + let history: ReturnType; + + const snapshot = (): SignalGraph => { + const graph = collectSignalGraph(ng, { env: 'root' })!; + history.collect(graph.nodes); + return graph; + }; + + beforeEach(async () => { + document.body.innerHTML = ''; + history = createSignalHistory( + (value, name) => serializeNamed(name, value, { budget: 1000 }), + Date.now, + (value, name) => graphValue(name, value), + { restorable: true }, + ); + unhook = await installSignalWriteHook(history.onWrite); + app = await bootstrapApplication(App, { providers: [provideZonelessChangeDetection()] }); + await settle(app); + ng = (globalThis as { ng?: SignalDebugNg }).ng!; + }); + + afterEach(() => { + app?.destroy(); + app = null; + unhook?.(); + unhook = null; + }); + + it('gives a service field a service locator and restores a past value', async () => { + const store = app!.injector.get(TripStore); + const first = snapshot(); + expect(first.injector?.name).toBe('Root'); + const trips = first.nodes.find((n) => n.label === 'trips')!; + expect(trips.locator).toEqual({ + id: first.injector!.id, + key: 'trips', + owner: 'service', + service: 'TripStore', + }); + store.trips.set(2); + await settle(app!); + const graph = snapshot(); + const initial = history.collect(graph.nodes)[trips.id]![0]!; + expect(initial.value).toBe(1); + + const result = restoreSignalEntry( + ng, + { component: graph.injector!.id, owner: 'service', node: trips.id, epoch: initial.epoch }, + history, + (id) => elementById(id), + ); + expect(result).toMatchObject({ ok: true, component: 'TripStore', key: 'trips', value: 1 }); + expect(store.trips()).toBe(1); + }); + + it('refuses a service restore for an injector that is gone', () => { + const graph = snapshot(); + const trips = graph.nodes.find((n) => n.label === 'trips')!; + expect( + restoreSignalEntry( + ng, + { component: 'inj-99999', owner: 'service', node: trips.id, epoch: 0 }, + history, + (id) => elementById(id), + ), + ).toEqual({ error: 'The injector is no longer on the page, so nothing was restored.' }); + }); +}); diff --git a/packages/devtools/src/config.ts b/packages/devtools/src/config.ts index 37c7269b..f653b628 100644 --- a/packages/devtools/src/config.ts +++ b/packages/devtools/src/config.ts @@ -1486,6 +1486,7 @@ export const RPC_INSPECTOR: Record = { 'request-ngrx-history': 'ngrx', 'import-ngrx-session': 'ngrx', 'request-provider-value': 'injectors', + 'request-injector-refresh': 'injectors', 'provider-value-result': 'injectors', 'push-redux-stores': 'ngrx', 'forget-redux-stores-page': 'ngrx', diff --git a/packages/devtools/src/devframe.ts b/packages/devtools/src/devframe.ts index 17986eff..7c3141c2 100644 --- a/packages/devtools/src/devframe.ts +++ b/packages/devtools/src/devframe.ts @@ -34,7 +34,7 @@ import { registerAudit } from './rpc/audit-register.ts'; import { inspectProvidersText } from './rpc/injector-tools.ts'; import { TOKEN_ID_ARGUMENT } from './rpc/token-match.ts'; import { registerInjectionExplain } from './rpc/injection-explain.ts'; -import { sanitizeInjectorTree } from './rpc/injector-report.ts'; +import { INJECTOR_TREE_LIMITS, sanitizeInjectorTree } from './rpc/injector-report.ts'; import { createFrameworkNames, markFramework } from './rpc/framework-tokens.ts'; import { lintPipes, lintPipesText } from './rpc/pipe-lint.ts'; import { cachedPipeLint } from './rpc/pipe-lint-cache.ts'; @@ -69,6 +69,7 @@ import { injectorMatches, isEnvironmentRequest, signalOwner } from './signal-gra import { scanNgrxStore } from './rpc/get-ngrx-store.ts'; import { createNgrxHistory } from './rpc/ngrx-session-tools.ts'; import { createProviderValues } from './rpc/provider-values.ts'; +import { requestInjectorRefresh } from './rpc/injector-refresh.ts'; import { dispatchResultText, expireNgrxPages, @@ -2722,7 +2723,9 @@ const pangular = defineDevframe({ pageId, roots: tree.roots, environment: tree.environment, - ...(report?.truncated === true || tree.truncated ? { truncated: true } : {}), + ...(report?.truncated === true || tree.truncated + ? { truncated: true, limits: INJECTOR_TREE_LIMITS } + : {}), ...(zone ? { zone } : {}), reportedAt: Date.now(), }; @@ -2781,6 +2784,20 @@ const pangular = defineDevframe({ providerValues.request(message?.pageId, message?.request), }); + // The panel asks the page to read an element injector again (the one it + // selected), or every injector (Refresh). The page pushes a fresh tree. + register({ + name: 'request-injector-refresh', + type: 'action', + jsonSerializable: true, + handler: (message: { pageId?: unknown; injectorId?: unknown } | null) => + requestInjectorRefresh( + message, + injectorPages.keys(), + (method, args) => void my.rpc.broadcast({ method, args: [args], optional: true }), + ), + }); + register({ name: 'push-ngrx-state', type: 'action', @@ -3738,7 +3755,14 @@ const pangular = defineDevframe({ } const matches = (g: PageGraph) => !bySelector && - ([g.componentSelector, g.component?.name, g.component?.id].includes(wanted) || + ([ + g.componentSelector, + g.component?.name, + g.component?.id, + g.element?.id, + g.element?.injectorId, + ].includes(wanted) || + (!!g.element && g.element.directives.includes(wanted)) || (!!g.injector && injectorMatches(g.injector, wanted))); const latestMatch = () => byRecency( @@ -3825,7 +3849,7 @@ const pangular = defineDevframe({ id: 'pangular:inspect-providers', title: 'Inspect injectors and providers', description: - 'DI injectors on the page: without arguments the injector tree with providers; `injector` narrows to one injector (component, Root, Platform, Route: x); `token` finds where a token is provided and injected.', + 'DI injectors on the page: without arguments the injector tree with providers; `injector` narrows to one injector (component, Root, Platform, Route: x); `token` finds where a token is provided and injected. `inactive: true` marks a lazy route injector off the rendered path.', inputSchema: { type: 'object', properties: { diff --git a/packages/devtools/src/effect-runs.ts b/packages/devtools/src/effect-runs.ts new file mode 100644 index 00000000..6128dc3b --- /dev/null +++ b/packages/devtools/src/effect-runs.ts @@ -0,0 +1,146 @@ +import { addProfilerListener, type Profiler, type ProfilerGate } from './change-detection.ts'; +import type { SignalGraphNode } from './types.ts'; + +/** + * Run counts and durations of `effect()`s, from Angular's profiler, where it + * reports effect runs. No Angular version up to 22.1 does: its + * `ProfilerEvent` enum has no effect event. So the tracker turns itself on + * only once it sees one, told apart by what the event carries: the effect's + * reactive node (`kind: 'effect'`), or an effect ref that holds one under its + * `SIGNAL` symbol. Angular numbers its profiler events in Start/End pairs, a + * Start even and its End the odd number after it, so the event number says + * which of the two it is. Until such an event arrives, nothing is attached + * to the graph and the panel shows nothing. + */ + +export interface EffectRunStats { + runs: number; + /** Total time of the runs that ended, in ms. */ + totalMs: number; + /** Time of the last run that ended, in ms. */ + lastMs: number; +} + +interface EffectNodeLike { + kind?: unknown; + debugName?: unknown; +} + +function effectNodeOf(instance: unknown): EffectNodeLike | null { + if (!instance || (typeof instance !== 'object' && typeof instance !== 'function')) return null; + try { + if ((instance as EffectNodeLike).kind === 'effect') return instance as EffectNodeLike; + for (const symbol of Object.getOwnPropertySymbols(instance)) { + if (symbol.description !== 'SIGNAL') continue; + const node = (instance as Record)[symbol] as EffectNodeLike | undefined; + return node && typeof node === 'object' && node.kind === 'effect' ? node : null; + } + } catch { + return null; + } + return null; +} + +export interface EffectRunTracker { + /** Whether the page's Angular reported an effect run at least once. */ + readonly active: boolean; + /** The stats of the effect with this name, when exactly one tracked effect has it. */ + statsFor(label: string): EffectRunStats | undefined; + /** Adds `runs`, `runMs` and `lastRunMs` to the effect nodes of a graph that have stats. */ + attach(nodes: SignalGraphNode[]): void; + stop(): void; +} + +/** Handles one profiler event; exported for tests that drive it without Angular. */ +export function createEffectRunListener(now: () => number = () => performance.now()) { + const stats = new Map(); + const started = new Map(); + let active = false; + const listener: Profiler = (event, instance) => { + const node = effectNodeOf(instance); + if (!node || !Number.isInteger(event)) return; + active = true; + if (event % 2 === 0) { + started.set(node, now()); + let entry = stats.get(node); + if (!entry) stats.set(node, (entry = { runs: 0, totalMs: 0, lastMs: 0 })); + entry.runs++; + return; + } + const start = started.get(node); + const entry = stats.get(node); + if (start === undefined || !entry) return; + started.delete(node); + entry.lastMs = Math.max(0, now() - start); + entry.totalMs += entry.lastMs; + }; + const statsFor = (label: string): EffectRunStats | undefined => { + let found: EffectRunStats | undefined; + for (const [node, entry] of stats) { + if (node.debugName !== label) continue; + if (found) return undefined; + found = entry; + } + return found; + }; + return { + listener, + get active() { + return active; + }, + statsFor, + /** Drops stats of effects that no longer run, past a bound. */ + trim(max = 500) { + while (stats.size > max) { + const first = stats.keys().next().value as EffectNodeLike; + stats.delete(first); + started.delete(first); + } + }, + }; +} + +const round = (ms: number) => Math.round(ms * 100) / 100; + +/** + * Listens to the page's Angular profiler for effect runs. Returns a tracker + * that stays inactive, and attaches nothing, where Angular reports none, or + * where the profiler is off limits (before Angular 20, see + * `addProfilerListener`). + */ +export function trackEffectRuns( + getNg: () => unknown, + where?: Document | ProfilerGate, + now?: () => number, +): EffectRunTracker { + const runs = createEffectRunListener(now); + let remove: (() => void) | null = null; + const ensure = () => { + if (remove) return; + remove = addProfilerListener(getNg(), runs.listener, where); + }; + ensure(); + return { + get active() { + return runs.active; + }, + statsFor: runs.statsFor, + attach(nodes) { + ensure(); + if (!runs.active) return; + runs.trim(); + for (const node of nodes) { + if (node.kind !== 'effect' || !node.label) continue; + const entry = runs.statsFor(node.label); + if (!entry) continue; + node.runs = entry.runs; + node.runMs = round(entry.totalMs); + node.lastRunMs = round(entry.lastMs); + } + }, + stop() { + remove?.(); + remove = null; + }, + }; +} diff --git a/packages/devtools/src/injector-refresh.ts b/packages/devtools/src/injector-refresh.ts new file mode 100644 index 00000000..78eda46a --- /dev/null +++ b/packages/devtools/src/injector-refresh.ts @@ -0,0 +1,41 @@ +import { forgetInjectorEntries } from './injector-tree.ts'; + +interface RefreshRpc { + register(definition: { + name: string; + type: 'event'; + jsonSerializable: boolean; + handler: (message: never) => unknown; + }): void; +} + +/** Longest injector id taken from a refresh request. */ +const MAX_ID = 100; + +/** + * Answers the server's `injector-refresh` events for this page: the panel + * selected an element injector (`injectorId`), or pressed Refresh (no id). + * The collector forgets what it read for that injector, or for all of them, + * and `push` sends a fresh injector tree. Element entries are cached because + * reading their dependencies is costly, so only these requests re-read them. + */ +export function registerInjectorRefresh( + rpc: RefreshRpc, + pageId: string, + push: () => Promise | unknown, +) { + rpc.register({ + name: 'injector-refresh', + type: 'event', + jsonSerializable: true, + handler: (message: { pageId?: unknown; injectorId?: unknown } | null) => { + if (!message || message.pageId !== pageId) return; + const id = message.injectorId; + if (id !== undefined && (typeof id !== 'string' || !id || id.length > MAX_ID)) return; + if (!forgetInjectorEntries(id)) return; + void Promise.resolve() + .then(push) + .catch(() => {}); + }, + }); +} diff --git a/packages/devtools/src/injector-tree.ts b/packages/devtools/src/injector-tree.ts index 1d157166..593ec69f 100644 --- a/packages/devtools/src/injector-tree.ts +++ b/packages/devtools/src/injector-tree.ts @@ -1,4 +1,4 @@ -import { routeInjectorName } from './route-injector-names.ts'; +import { routeInjectorName, routeInjectors } from './route-injector-names.ts'; import type { DependencyInfo, InjectorInfo, @@ -20,6 +20,7 @@ interface ProviderRecord { export interface DebugNg { getInjector?(el: H): unknown; + ɵgetRouterInstance?(injector: unknown): unknown; getComponent?(el: H): unknown; getDirectives?(node: H): unknown[]; ɵgetInjectorMetadata?(injector: unknown): { type: string; source: unknown } | null; @@ -401,7 +402,7 @@ function isCreated(record: InjectorRecord): boolean { return !(Object.getPrototypeOf(value) === Object.prototype && Object.keys(value).length === 0); } -const serviceDependencies = new WeakMap>(); +let serviceDependencies = new WeakMap>(); /** What the services an environment injector already created inject. */ function environmentDependencies( @@ -439,7 +440,34 @@ function environmentDependencies( return out.slice(0, MAX_SERVICE_DEPENDENCIES); } -const elementEntries = new WeakMap(); +/** + * What each element injector holds, read once. Element provider records don't + * change, and the dependency list comes from `ɵgetDependenciesFromInjectable`, + * which is too costly to call for every element on every tick. The panel asks + * for a fresh read of the element it selects, and of everything on Refresh + * ({@link forgetInjectorEntries}). + */ +let elementEntries = new WeakMap(); +/** Element hosts the tree has read, so a refresh can tell an element id from an environment one. */ +const elementHosts = new WeakSet(); + +/** + * Drops what the collector remembers, so the next collection reads it again: + * one element injector by its id, or every element injector and the + * dependencies of every environment injector's services without one. + * Answers whether anything was dropped (always true without an id). + */ +export function forgetInjectorEntries(injectorId?: string): boolean { + if (injectorId === undefined) { + elementEntries = new WeakMap(); + serviceDependencies = new WeakMap(); + return true; + } + const key = byId.get(injectorId)?.deref(); + if (!key) return false; + if (elementHosts.has(key)) return elementEntries.delete(key); + return serviceDependencies.delete(key); +} function environmentsOf(ng: DebugNg, path: unknown[]): object[] { return path.filter( @@ -511,9 +539,101 @@ function readElement( environments: environmentsOf(ng, path), }; elementEntries.set(el, entry); + elementHosts.add(el); return entry; } +/** Environment injectors walked up from one, at most. */ +const MAX_ENVIRONMENT_CHAIN = 100; + +/** + * `injector` and its parent environment injectors, nearest first, through + * `R3Injector.parent` (every Ivy version), up to the null injector. + */ +function environmentChain(ng: DebugNg, injector: object): object[] { + const chain: object[] = []; + let at: unknown = injector; + while (at && typeof at === 'object' && chain.length < MAX_ENVIRONMENT_CHAIN) { + if (chain.includes(at) || injectorMetadata(ng, at)?.type !== 'environment') break; + chain.push(at); + try { + at = (at as { parent?: unknown }).parent; + } catch { + break; + } + } + return chain; +} + +/** + * The app's Router, from the root injector: through `ɵgetRouterInstance` + * (Angular 18.1+, from `provideRouter()`), else the root injector's own record + * for a token named `Router`, when the router was already created. + */ +function rootRouter(ng: DebugNg, root: object): unknown { + try { + const router = ng.ɵgetRouterInstance?.(root); + if (router && typeof router === 'object') return router; + } catch { + // Read from the records below. + } + for (const [token, record] of ownRecords(root)) { + if (typeof token !== 'function' || tokenName(token) !== 'Router') continue; + return isCreated(record) ? record.value : undefined; + } + return undefined; +} + +/** + * The route environment injectors the router created that are not among + * `known`, the environment injectors the rendered elements' lookup paths + * reached. Read from the route objects of `router.config` (`_injector`, + * `_loadedInjector`), so a lazy route the app navigated away from still + * shows, as inactive. + */ +function offPathRouteInjectors( + ng: DebugNg, + known: Iterable, +): object[] { + const seen = new Set(known); + const root = [...seen].find((injector) => + (injector as { scopes?: Set }).scopes?.has?.('root'), + ); + if (!root) return []; + return routeInjectors(rootRouter(ng, root)).filter( + (injector) => !seen.has(injector) && injectorMetadata(ng, injector)?.type === 'environment', + ); +} + +/** + * The injector object behind an id the injector tree or the signal graph + * gave out: an environment injector itself, or an element injector's host + * element. Undefined once it is gone. + */ +export function injectorKeyById(id: string): { key: object; element: boolean } | undefined { + const key = byId.get(id)?.deref(); + return key ? { key, element: elementHosts.has(key) } : undefined; +} + +/** Most services {@link createdServices} returns. */ +const MAX_CREATED_SERVICES = 200; + +/** + * The service instances an environment injector already created, read from + * its records without creating anything: class and factory records whose + * value is an object. + */ +export function createdServices(injector: unknown): object[] { + const out: object[] = []; + for (const [, record] of ownRecords(injector)) { + if (out.length >= MAX_CREATED_SERVICES) break; + if (record.multi || !isCreated(record)) continue; + const value = record.value; + if (value && typeof value === 'object' && !out.includes(value)) out.push(value); + } + return out; +} + export function collectInjectorTree( ng: DebugNg | undefined, tree: HostTree = pageTree(), @@ -525,7 +645,7 @@ export function collectInjectorTree( const records = recordReader(ng); const envs = new Map(); - const noteEnvironment = (chain: object[]) => { + const noteEnvironment = (chain: object[], inactive = false) => { chain.forEach((injector, index) => { if (envs.has(injector)) return; const meta = injectorMetadata(ng, injector); @@ -538,6 +658,7 @@ export function collectInjectorTree( type: 'environment', name: environmentName(injector, meta?.source), providerCount: providers.length, + ...(inactive ? { inactive: true } : {}), }, providers, children: [], @@ -595,6 +716,14 @@ export function collectInjectorTree( notePairs(roots); injectedPairs = pairs; + // Lazy route injectors the router already created but no rendered + // element's lookup path goes through, such as a route the app left. + if (!truncated) { + for (const injector of offPathRouteInjectors(ng, envs.keys())) { + noteEnvironment(environmentChain(ng, injector), true); + } + } + const environment: InjectorTreeNode[] = []; for (const env of envs.values()) { const parent = env.parent ? envs.get(env.parent) : undefined; @@ -636,7 +765,7 @@ export function providerValue( const verb = { store: 'Store', log: 'Log', read: 'Read' }[mode]; const key = byId.get(injectorId)?.deref(); if (!ng || !key) return { error: 'This injector is no longer on the page.' }; - const element = elementEntries.has(key); + const element = elementHosts.has(key); let injector: unknown = key; if (element) { try { diff --git a/packages/devtools/src/overlay-angular-native.ts b/packages/devtools/src/overlay-angular-native.ts index b26fd62d..3f17280e 100644 --- a/packages/devtools/src/overlay-angular-native.ts +++ b/packages/devtools/src/overlay-angular-native.ts @@ -9,6 +9,7 @@ import { setRedaction } from './forms-privacy.ts'; import { legacyRedaction, serverRedaction } from './overlay-redaction.ts'; import { hostBySelector } from './host-tree.ts'; import { collectInjectorTree } from './injector-tree.ts'; +import { registerInjectorRefresh } from './injector-refresh.ts'; import { gateInjectorProfilerInReactNativeDev } from './injector-profiler-gate.ts'; import { attachNativeDiagnostics, errorUtilsTarget, nativeDebugApi } from './native-diagnostics.ts'; import { attachNgrx } from './ngrx-overlay.ts'; @@ -334,6 +335,8 @@ async function startSession( throw error; } } + // Re-reads the element injector the panel selected, or every injector on Refresh. + if (on.injectors) registerInjectorRefresh(my.rpc, pageId, pushInjectorTree); const ngrx = on.ngrx ? attachNgrx(my, pageId, angularDebugApi, config.limits.changeLog, { diff --git a/packages/devtools/src/overlay-nativescript.ts b/packages/devtools/src/overlay-nativescript.ts index c108075e..cbd1652f 100644 --- a/packages/devtools/src/overlay-nativescript.ts +++ b/packages/devtools/src/overlay-nativescript.ts @@ -18,6 +18,7 @@ import { setRedaction } from './forms-privacy.ts'; import { legacyRedaction, serverRedaction } from './overlay-redaction.ts'; import { hostBySelector } from './host-tree.ts'; import { collectInjectorTree } from './injector-tree.ts'; +import { registerInjectorRefresh } from './injector-refresh.ts'; import { gateInjectorProfiler } from './injector-profiler-gate.ts'; import { applicationErrorsTarget, @@ -352,6 +353,8 @@ async function startSession( throw error; } } + // Re-reads the element injector the panel selected, or every injector on Refresh. + if (on.injectors) registerInjectorRefresh(my.rpc, pageId, pushInjectorTree); const ngrx = on.ngrx ? attachNgrx(my, pageId, angularDebugApi, config.limits.changeLog, { diff --git a/packages/devtools/src/overlay.ts b/packages/devtools/src/overlay.ts index f7a7f1a1..cd3621be 100644 --- a/packages/devtools/src/overlay.ts +++ b/packages/devtools/src/overlay.ts @@ -27,6 +27,8 @@ import { attachReduxStores } from './redux-stores-collector.ts'; import { uninstallReduxDevtoolsHook } from './redux-devtools-hook.ts'; import { attachTanstackQuery } from './query-collector.ts'; import { collectInjectorTree } from './injector-tree.ts'; +import { registerInjectorRefresh } from './injector-refresh.ts'; +import { trackEffectRuns } from './effect-runs.ts'; import { registerProviderValues } from './provider-values.ts'; import { clearNavigations, @@ -443,6 +445,10 @@ async function startOverlay( if (!own(restoreSignalHook ?? (() => {}))) return; const writeHookMissing = on.signals && !restoreSignalHook; + // Effect run counts, where the page's Angular profiler reports effect runs. + const effectRuns = on.signals ? trackEffectRuns(getNg) : null; + if (effectRuns) own(effectRuns.stop); + let signalTarget: SignalTarget = null; let lastSignalKey = ''; let signalSentAt = 0; @@ -452,6 +458,7 @@ async function startOverlay( async function pushSignalGraph(force = false) { const graph = signalWatch.attach(collectSignalGraph(getNg(), signalTarget)); if (!graph) return; + effectRuns?.attach(graph.nodes); const key = graphKey(graph); if (!force && key === lastSignalKey) { if (!keepaliveDue(signalSentAt, tickMs())) return; @@ -531,6 +538,8 @@ async function startOverlay( const injectorChannel = channel('push-injector-tree', 'ping-injector-tree', INJECTOR_TREE_SHAPE); // Reads the value of a provider the injectors already created, for the Injectors view. if (on.injectors) registerProviderValues(my.rpc, pageId, getNg); + // Re-reads the element injector the panel selected, or every injector on Refresh. + if (on.injectors) registerInjectorRefresh(my.rpc, pageId, () => pushInjectorTree(true)); // Wakes up with a push of every leased report when a panel or an agent looks again. const lease = createPageLease(my, pageId, { wake: () => diff --git a/packages/devtools/src/panel-types.ts b/packages/devtools/src/panel-types.ts new file mode 100644 index 00000000..83e4ee1d --- /dev/null +++ b/packages/devtools/src/panel-types.ts @@ -0,0 +1,19 @@ +/** + * The report shapes the panel (`app/`) reads, for the panel's own imports. + * Internal: a workspace-only `exports` entry (`./internal/types`) that + * `publishConfig` leaves out, like `./internal/config`. Type-only + * re-exports, so nothing from the package ends up in the panel bundle; + * `panel-types.test.ts` keeps it that way. + */ +export type { + DependencyInfo, + InjectorInfo, + InjectorTreeLimits, + InjectorTreeNode, + InjectorTreeReport, + ProviderInfo, + SignalChange, + SignalGraphElement, + SignalGraphNode, + SignalWriteLocator, +} from './types.ts'; diff --git a/packages/devtools/src/route-injector-names.ts b/packages/devtools/src/route-injector-names.ts index cd5e6aef..c3520aaa 100644 --- a/packages/devtools/src/route-injector-names.ts +++ b/packages/devtools/src/route-injector-names.ts @@ -23,9 +23,22 @@ function read(fn: () => T, fallback: T): T { /** Notes the full path of every route that has its own environment injector. */ export function noteRouteInjectors(router: unknown): void { + routeInjectors(router); +} + +/** + * The environment injectors the router created for its routes, in config + * order: a route's `providers` injector (`_injector`) and the injector of the + * module or routes a `loadChildren` loaded (`_loadedInjector`). Both are set + * by `@angular/router` on the route objects of `router.config`, and stay + * there once the user navigates away. Notes the full path of each route's + * `_injector` on the way, for {@link routeInjectorName}. + */ +export function routeInjectors(router: unknown): object[] { const config = read(() => (router as AnyRecord)['config'] as AnyRecord[] | undefined, undefined); - if (!Array.isArray(config)) return; + if (!Array.isArray(config)) return []; const seen = new Set(); + const out = new Set(); const visit = (routes: unknown, parent: string, depth: number) => { if (!Array.isArray(routes) || depth > MAX_DEPTH) return; for (const route of routes as AnyRecord[]) { @@ -34,7 +47,12 @@ export function noteRouteInjectors(router: unknown): void { const path = read(() => (typeof route['path'] === 'string' ? route['path'] : ''), ''); const full = [parent, path].filter(Boolean).join('/'); const injector = read(() => route['_injector'], undefined); - if (injector && typeof injector === 'object') fullPaths.set(injector, full); + if (injector && typeof injector === 'object') { + fullPaths.set(injector, full); + out.add(injector); + } + const loaded = read(() => route['_loadedInjector'], undefined); + if (loaded && typeof loaded === 'object') out.add(loaded); visit( read(() => route['children'], undefined), full, @@ -48,6 +66,7 @@ export function noteRouteInjectors(router: unknown): void { } }; visit(config, '', 0); + return [...out]; } /** diff --git a/packages/devtools/src/rpc/__tests__/get-providers.test.ts b/packages/devtools/src/rpc/__tests__/get-providers.test.ts index c3c1811d..f06c13af 100644 --- a/packages/devtools/src/rpc/__tests__/get-providers.test.ts +++ b/packages/devtools/src/rpc/__tests__/get-providers.test.ts @@ -288,4 +288,28 @@ class Settings {}`, ['AlsoLocal', undefined], ]); }); + + it('lists a provide*() helper it cannot resolve as an unresolved helper', async () => { + const providers = await providersFor(` + export function provideFeature(): EnvironmentProviders { + return makeEnvironmentProviders([]); + } + class Builder { + provideThing(): Provider[] { + return []; + } + } + export const appConfig = { + providers: [provideRouter(routes), provideFeature({ on: true }), lib.provideOther()], + }; + `); + expect(providers.map((p) => [p.token, p.type])).toEqual([ + ['Router', 'root-provider'], + ['provideFeature()', 'helper'], + ]); + expect(providers[1]).toEqual( + expect.objectContaining({ source: 'provideFeature()', line: 11, file: 'src/app.ts' }), + ); + expect(providers[1]!.providedIn).toBeUndefined(); + }); }); diff --git a/packages/devtools/src/rpc/get-providers.ts b/packages/devtools/src/rpc/get-providers.ts index 41b87204..cf4805d0 100644 --- a/packages/devtools/src/rpc/get-providers.ts +++ b/packages/devtools/src/rpc/get-providers.ts @@ -69,6 +69,23 @@ const PROVIDE_FN_TO_TOKEN: Record = { provideEnvironmentInitializer: 'EnvironmentInitializer', }; +/** The `type` of a provide*() call the scan can't resolve to the tokens it registers. */ +export const HELPER_TYPE = 'helper'; + +/** + * Whether the `provideX(` at `at` calls a function, as opposed to declaring + * one (`function provideX(`, a method `provideX() {`) or calling a member + * of some object (`foo.provideX(`). + */ +function isHelperCall(code: string, at: number): boolean { + const before = code.slice(Math.max(0, at - 40), at); + if (/(?:\bfunction\s*\*?\s*|\.\s*|#)$/.test(before)) return false; + const open = code.indexOf('(', at); + const close = matchDelimiter(code, open, '(', ')'); + // `provideX(...) {` or `provideX(...): Type {` declares a method. + return !/^\s*(?::[^;{}()=]*)?\{/.test(code.slice(close + 1, close + 200)); +} + interface ProviderEntry { token: string; source: string; @@ -174,6 +191,16 @@ function scanFile(full: string, cwd: string, out: ProviderEntry[]) { providedIn: 'root', type: 'root-provider', }); + } else if (isHelperCall(code, match.index!)) { + // A provide*() function the scan can't resolve to a token, such as + // the app's own `provideFeature()` or one from a library. + out.push({ + token: fnName + '()', + source: fnName + '()', + file: relPath, + line: lineAt(match.index!), + type: HELPER_TYPE, + }); } } diff --git a/packages/devtools/src/rpc/injector-outline.ts b/packages/devtools/src/rpc/injector-outline.ts index 26368ba0..89f45b3f 100644 --- a/packages/devtools/src/rpc/injector-outline.ts +++ b/packages/devtools/src/rpc/injector-outline.ts @@ -8,6 +8,8 @@ export interface InjectorOutline { component?: string; /** The component's id in the component tree. */ componentId?: string; + /** A lazy route's environment injector off the rendered path. */ + inactive?: true; providers: number; dependencies: number; children: InjectorOutline[]; @@ -40,7 +42,7 @@ export function outlineInjectors( list .filter((node) => kept.has(node)) .map((node) => { - const { id, name, type, component, componentId } = node.injector; + const { id, name, type, component, componentId, inactive } = node.injector; const children = build(node.children); const more = node.children.length - children.length; return { @@ -49,6 +51,7 @@ export function outlineInjectors( type, ...(component ? { component } : {}), ...(componentId ? { componentId } : {}), + ...(inactive ? { inactive: true as const } : {}), providers: node.providers.length, dependencies: node.dependencies?.length ?? 0, children, diff --git a/packages/devtools/src/rpc/injector-refresh.ts b/packages/devtools/src/rpc/injector-refresh.ts new file mode 100644 index 00000000..4567bc1c --- /dev/null +++ b/packages/devtools/src/rpc/injector-refresh.ts @@ -0,0 +1,30 @@ +/** Longest injector id a refresh request takes. */ +const MAX_ID = 100; + +export type InjectorRefreshResult = { ok: true } | { ok: false; error: string }; + +/** + * The panel's `request-injector-refresh`: asks one page to read an element + * injector again (`injectorId`, the one the panel selected), or every injector + * (no id, the Refresh button). The page answers with a fresh injector tree + * push, so this only checks the request and sends `injector-refresh`. + */ +export function requestInjectorRefresh( + message: { pageId?: unknown; injectorId?: unknown } | null, + pages: Iterable, + broadcast: (method: string, message: { pageId: string; injectorId?: string }) => void, +): InjectorRefreshResult { + const pageId = message?.pageId; + if (typeof pageId !== 'string' || !pageId) { + return { ok: false, error: 'Pass the page id of the injector tree.' }; + } + if (![...pages].includes(pageId)) { + return { ok: false, error: `Page ${pageId.slice(0, 100)} has not reported an injector tree.` }; + } + const id = message?.injectorId; + if (id !== undefined && id !== null && (typeof id !== 'string' || !id || id.length > MAX_ID)) { + return { ok: false, error: 'Pass an injector id from the injector tree.' }; + } + broadcast('injector-refresh', typeof id === 'string' ? { pageId, injectorId: id } : { pageId }); + return { ok: true }; +} diff --git a/packages/devtools/src/rpc/injector-report.ts b/packages/devtools/src/rpc/injector-report.ts index 9d2af9c7..d9148ec9 100644 --- a/packages/devtools/src/rpc/injector-report.ts +++ b/packages/devtools/src/rpc/injector-report.ts @@ -1,5 +1,10 @@ import * as v from 'valibot'; -import type { DependencyInfo, InjectorTreeNode, ProviderInfo } from '../types.ts'; +import type { + DependencyInfo, + InjectorTreeLimits, + InjectorTreeNode, + ProviderInfo, +} from '../types.ts'; import { MAX_INJECTOR_NODES, MAX_SERVICE_DEPENDENCIES } from '../injector-tree.ts'; /** Element injectors plus room for the environment injectors around them. */ @@ -10,6 +15,13 @@ const MAX_PROVIDERS = 1000; /** How deep an injector can sit before the server leaves it out. */ export const MAX_INJECTOR_DEPTH = 1000; +/** The caps a truncated report names (`InjectorTreeReport.limits`). */ +export const INJECTOR_TREE_LIMITS: InjectorTreeLimits = { + elements: MAX_INJECTOR_NODES, + injectors: MAX_REPORTED_INJECTORS, + depth: MAX_INJECTOR_DEPTH, +}; + const clipped = (max: number) => v.pipe( v.string(), @@ -33,6 +45,7 @@ const InjectorSchema = v.object({ selector: v.optional(clipped(MAX_SELECTOR)), path: v.optional(texts(200)), host: v.optional(text), + inactive: v.optional(v.boolean()), }); // `tokenId` is optional: reports from older overlays have none and match by name. diff --git a/packages/devtools/src/rpc/scan-tools.ts b/packages/devtools/src/rpc/scan-tools.ts index e1662498..df6bb5c7 100644 --- a/packages/devtools/src/rpc/scan-tools.ts +++ b/packages/devtools/src/rpc/scan-tools.ts @@ -65,7 +65,7 @@ export const SCAN_TOOLS: readonly ScanToolSpec[] = [ id: 'pangular:scan-providers', title: 'Scan Angular DI providers', description: - 'Source scan of DI providers as JSON: @Injectable services, inject() calls, constructor parameters of decorated classes and providers arrays, with token, file and where each is provided. pangular:inspect-providers reads the live injectors.', + 'Source scan of DI providers as JSON: @Injectable services, inject() calls, constructor parameters of decorated classes and providers arrays, with token, file and where each is provided. Other provide*() calls have type `helper`. pangular:inspect-providers reads the live injectors.', definition: getProviders as unknown as ScanDefinition, }, { diff --git a/packages/devtools/src/rpc/signal-peeks.ts b/packages/devtools/src/rpc/signal-peeks.ts index a216dccc..c747b881 100644 --- a/packages/devtools/src/rpc/signal-peeks.ts +++ b/packages/devtools/src/rpc/signal-peeks.ts @@ -72,8 +72,9 @@ interface ComponentHit { /** * What inspect-signals asks the page for when no pushed graph matches: the * CSS `selector` as given, an environment injector for `root`, a route path - * or an injector id, or the first component instance `hits` found for a tag, - * class name or instance id. `pageId` is the page that instance is on. Null + * or an injector id (an element injector id reads that element's graph), or + * the first component instance `hits` found for a tag, class name or + * instance id, else the first directive host. `pageId` is the page that instance is on. Null * when nothing can be asked: the component tree has no such component. */ export function signalPeekFor( @@ -83,7 +84,8 @@ export function signalPeekFor( if (args.selector !== undefined) return { target: { selector: args.selector } }; const wanted = args.component ?? ''; if (isEnvironmentRequest(wanted)) return { target: { env: wanted } }; - const hit = hits.find((entry) => entry.node.kind !== 'directive'); + // A component first; a host with directives but no component has a graph too. + const hit = hits.find((entry) => entry.node.kind !== 'directive') ?? hits[0]; return hit ? { target: { id: hit.node.id }, pageId: hit.pageId } : null; } diff --git a/packages/devtools/src/rpc/signal-restores.ts b/packages/devtools/src/rpc/signal-restores.ts index 496a121b..b45adf12 100644 --- a/packages/devtools/src/rpc/signal-restores.ts +++ b/packages/devtools/src/rpc/signal-restores.ts @@ -49,10 +49,16 @@ export function createSignalRestores(deps: Deps) { return resolve({ error: 'Pass the component id, the signal node id and the entry epoch.' }); } const node = graph.nodes.find((n) => n.id === restore.node); - if (!node?.locator || node.locator.id !== restore.component) { + const service = restore.owner === 'service'; + if ( + !node?.locator || + node.locator.id !== restore.component || + (node.locator.owner === 'service') !== service + ) { return resolve({ - error: - 'This signal is not a writable field of the shown component, so it cannot be restored.', + error: service + ? 'This signal is not a writable field of a service of the shown injector, so it cannot be restored.' + : 'This signal is not a writable field of the shown component, so it cannot be restored.', }); } const requestId = `r${++seq}`; diff --git a/packages/devtools/src/signal-graph.ts b/packages/devtools/src/signal-graph.ts index e228ce07..26fdb9ea 100644 --- a/packages/devtools/src/signal-graph.ts +++ b/packages/devtools/src/signal-graph.ts @@ -1,7 +1,7 @@ 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 { className, createdServices, injectorKeyById, injectorRef } from './injector-tree.ts'; import { serializeNamed } from './serialize.ts'; import { frameworkNodes, type OwnSignal } from './signal-framework.ts'; import { locateWritable, setRawValue } from './signal-locator.ts'; @@ -10,6 +10,7 @@ import { cleanValue, groupResources, rawNodeOf, type RawNode } from './signal-re import type { SignalGraph, SignalGraphEdge, + SignalGraphElement, SignalGraphInjector, SignalGraphNode, SignalNodeKind, @@ -221,18 +222,46 @@ function markFramework( type RawGraph = NonNullable>>; +/** The services of an environment injector, whose writable fields can be restored. */ +interface ServiceOwners { + injectorId: string; + instances: object[]; +} + +type LocatedField = ReturnType extends Map ? F : never; + +/** The writable fields of the services behind graph nodes, by node index; first service wins. */ +function locateServices( + ng: SignalDebugNg, + owners: ServiceOwners, + raw: RawGraph, +): Map { + const out = new Map(); + for (const instance of owners.instances) { + const found = locateWritable(ng, instance, raw.nodes, raw.edges); + const service = className((instance as { constructor: new () => unknown }).constructor); + for (const [index, field] of found) { + if (!out.has(index)) out.set(index, { field, service }); + } + } + return out; +} + function buildGraph( raw: RawGraph, instance: object | null, ng: SignalDebugNg, secrets: ReadonlySet, componentId?: string, + services?: ServiceOwners, ) { const value = (label: string | undefined, raw: unknown) => graphValue(label, raw, secrets); let linked: LinkedReader[] | null = null; const kept = raw.nodes.slice(0, MAX_NODES); const located = instance && componentId ? locateWritable(ng, instance, raw.nodes, raw.edges) : new Map(); + const serviceFields = + !instance && services?.instances.length ? locateServices(ng, services, raw) : null; const twins = new Map(); for (const n of kept) { if (n.kind !== 'linkedSignal' || 'value' in n) continue; @@ -246,8 +275,16 @@ function buildGraph( epoch: n.epoch ?? 0, }; if (n.label) node.label = n.label; - const field = located.get(index); - if (field) node.locator = { id: componentId!, key: field.key }; + const owned = serviceFields?.get(index); + const field = located.get(index) ?? owned?.field; + if (owned) { + node.locator = { + id: services!.injectorId, + key: owned.field.key, + owner: 'service', + service: owned.service, + }; + } else if (field) node.locator = { id: componentId!, key: field.key }; // Angular sends no value for linkedSignal nodes; the matched field holds it. const fieldValue = field && !('value' in n) ? cleanValue(field.node) : null; if ('value' in n) { @@ -323,6 +360,64 @@ function graphFor( }; } +/** + * The graph of any host with an element injector: a component host as + * `graphFor` reads it, or a host with directives but no component, whose + * graph holds the effects its directives created. `injectorId` is the element + * injector id the graph was asked for by, if any. + */ +function graphForHost( + ng: SignalDebugNg, + el: H, + source: NonNullable, + tree: HostTree, + injectorId?: string, +): SignalGraph | null { + const directives = read(() => ng.getDirectives?.(el) ?? [], [] as unknown[]).filter( + (d): d is object => !!d && typeof d === 'object', + ); + const names = directives.map((d) => + className((d as { constructor: new () => unknown }).constructor), + ); + const tag = tree.tag(el); + const element = (id: string): SignalGraphElement => ({ + id, + tag, + directives: names, + ...(injectorId ? { injectorId } : {}), + }); + if (isComponentHost(ng, el)) { + const graph = graphFor(ng, el, source, tree); + if (graph && !graph.unsupported && injectorId && graph.component) { + graph.element = element(graph.component.id); + } + return graph; + } + if (!directives.length) return null; + const injector = read(() => ng.getInjector?.(el), null); + if (!injector) return null; + const raw = rawGraph(ng, injector); + if (!raw) return null; + if (!hasIds(raw)) return UNSUPPORTED; + const id = elementId(el); + return { + // No `componentId`: a directive's fields are not restored, so no locators. + ...buildGraph(raw, directives[0]!, ng, signalSecrets(ng, el, directives[0]!)), + componentSelector: tag, + element: element(id), + source, + }; +} + +/** The host element behind an element injector id from the injector tree. */ +function elementInjectorHost(id: string, tree: HostTree): H | null { + if (!/^inj-\d+$/.test(id)) return null; + const found = injectorKeyById(id); + if (!found?.element) return null; + const host = found.key as H; + return tree.isHost(host) && tree.connected(host) ? host : null; +} + interface Environment extends SignalGraphInjector { injector: object; } @@ -382,7 +477,10 @@ function graphForEnvironment( if (!raw) return null; if (!hasIds(raw)) return UNSUPPORTED; return { - ...buildGraph(raw, null, ng, signalSecrets(ng, null, null)), + ...buildGraph(raw, null, ng, signalSecrets(ng, null, null), undefined, { + injectorId: env.id, + instances: createdServices(env.injector), + }), injector: { id: env.id, name: env.name }, source: 'selected', }; @@ -421,10 +519,16 @@ function pickGraph( const env = environments.find((e) => injectorMatches(e, target.env)); const graph = env ? graphForEnvironment(ng, env) : null; if (graph) return graph; + // An element injector id from the injector tree reads that element's graph. + const host = elementInjectorHost(target.env.trim(), source.tree); + const hostGraph = host + ? graphForHost(ng, host, 'selected', source.tree, target.env.trim()) + : null; + if (hostGraph) return hostGraph; } const picked = resolveTarget(target, source); - if (isComponentHost(ng, picked)) { - const graph = graphFor(ng, picked, 'selected', source.tree); + if (picked) { + const graph = graphForHost(ng, picked, 'selected', source.tree); if (graph) return graph; } const routed = source.routed(ng); @@ -442,17 +546,25 @@ function pickGraph( } /** The component or environment injector a graph belongs to; node ids are only unique within it. */ -export function signalOwner(graph: Pick): string { - return graph.component?.id ?? graph.injector?.id ?? ''; +export function signalOwner( + graph: Pick, +): string { + return graph.component?.id ?? graph.injector?.id ?? graph.element?.id ?? ''; } export function graphKey(graph: SignalGraph): string { const nodes = graph.nodes - .map((n) => `${n.id}:${n.epoch}${n.locator ? `=${n.locator.key}` : ''}`) + .map( + (n) => + `${n.id}:${n.epoch}${n.locator ? `=${n.locator.key}` : ''}${n.runs ? `#${n.runs}` : ''}`, + ) .join(','); const edges = graph.edges.map((e) => `${e.consumer}>${e.producer}`).join(','); const owner = - graph.component?.id ?? graph.injector?.id ?? (graph.unsupported ? 'unsupported' : ''); + graph.component?.id ?? + graph.injector?.id ?? + graph.element?.id ?? + (graph.unsupported ? 'unsupported' : ''); const envs = (graph.environments ?? []).map((e) => e.id).join(','); const watch = graph.watch ? `${graph.watch.stacks ? 's' : ''}:${graph.watch.ids.join(',')}` : ''; return `${owner}|${graph.source ?? ''}|${nodes}|${edges}|${envs}|${watch}`; diff --git a/packages/devtools/src/signal-peek.ts b/packages/devtools/src/signal-peek.ts index 042fb1ad..5ec9574b 100644 --- a/packages/devtools/src/signal-peek.ts +++ b/packages/devtools/src/signal-peek.ts @@ -50,10 +50,16 @@ export function toPeekRequest(request: unknown, pageId: string): SignalPeekReque /** Whether the collected graph is the one the request asked for, not a fallback. */ function answers(graph: SignalGraph, request: SignalPeekRequest): boolean { - if ('env' in request) return !!graph.injector && injectorMatches(graph.injector, request.env); - // `selected` means the target resolved to a component host; anything else is a fallback. - if (graph.source !== 'selected' || !graph.component) return false; - return 'id' in request ? graph.component.id === request.id : true; + if ('env' in request) { + if (graph.injector) return injectorMatches(graph.injector, request.env); + // An element injector id from the injector tree (`inj-N`). + return graph.source === 'selected' && graph.element?.injectorId === request.env.trim(); + } + // `selected` means the target resolved to a host; anything else is a fallback. + if (graph.source !== 'selected') return false; + const id = graph.component?.id ?? graph.element?.id; + if (!id) return false; + return 'id' in request ? id === request.id : true; } function targetOf(request: SignalPeekRequest): SignalTarget { diff --git a/packages/devtools/src/signal-restore.ts b/packages/devtools/src/signal-restore.ts index 3e2e7c0d..c9e255f9 100644 --- a/packages/devtools/src/signal-restore.ts +++ b/packages/devtools/src/signal-restore.ts @@ -2,7 +2,7 @@ import type { ComponentDebugNg } from './component-tree.ts'; import { actionBlockedMessage } from './config.ts'; import { elementById } from './element-id.ts'; import { documentTree, type HostTree } from './host-tree.ts'; -import { className } from './injector-tree.ts'; +import { className, createdServices, injectorKeyById } from './injector-tree.ts'; import { serializeNamed } from './serialize.ts'; import type { RawEntryLookup } from './signal-history.ts'; import { boundField } from './signal-locator.ts'; @@ -17,8 +17,13 @@ import { boundField } from './signal-locator.ts'; */ export interface SignalRestoreRequest { - /** The component instance id from the node's `locator`. */ + /** + * The component instance id from the node's `locator`; with + * `owner: 'service'` in the locator, the environment injector id. + */ component: string; + /** `service` when the signal is a field of a service an environment injector created. */ + owner?: 'service'; /** The graph node id. */ node: string; /** The `epoch` of the history entry to restore. */ @@ -58,7 +63,36 @@ export function toRestoreRequest(value: unknown): SignalRestoreRequest | null { if (typeof component !== 'string' || !component || component.length > 100) return null; if (typeof node !== 'string' || !node || node.length > 100) return null; if (typeof epoch !== 'number' || !Number.isInteger(epoch) || epoch < 0) return null; - return { component, node, epoch }; + if (r['owner'] !== undefined && r['owner'] !== 'service') return null; + return r['owner'] === 'service' + ? { component, owner: 'service', node, epoch } + : { component, node, epoch }; +} + +/** + * The service and its writable field behind a node of an environment + * injector's graph: the created services of the injector with that id, the + * first one whose field the last collect bound to the node. + */ +function serviceField( + ng: ComponentDebugNg, + injectorId: string, + node: string, +): { owner: string; field: NonNullable> } | string { + const found = injectorKeyById(injectorId); + if (!found || found.element) { + return 'The injector is no longer on the page, so nothing was restored.'; + } + for (const service of createdServices(found.key)) { + const field = boundField(ng, service, node); + if (field) { + return { + owner: className((service as { constructor: new () => unknown }).constructor), + field, + }; + } + } + return 'This signal is not a writable field of a service the page could match, so it cannot be restored.'; } export function restoreSignalEntry( @@ -70,17 +104,26 @@ export function restoreSignalEntry( if (!ng?.getComponent) return fail('Angular debug APIs are not available on this page.'); const wanted = toRestoreRequest(request); if (!wanted) return fail('Pass the component id, the signal node id and the entry epoch.'); - const host = find(wanted.component); - const instance = host ? read(() => ng.getComponent?.(host) ?? null, null) : null; - if (!instance || typeof instance !== 'object') { - return fail('The component is no longer on the page, so nothing was restored.'); - } - const component = className((instance as { constructor: new () => unknown }).constructor); - const field = boundField(ng, instance, wanted.node); - if (!field) { - return fail( - `This signal is not a writable field of ${component} the page could match, so it cannot be restored.`, - ); + let component: string; + let field: ReturnType; + if (wanted.owner === 'service') { + const found = serviceField(ng as ComponentDebugNg, wanted.component, wanted.node); + if (typeof found === 'string') return fail(found); + component = found.owner; + field = found.field; + } else { + const host = find(wanted.component); + const instance = host ? read(() => ng.getComponent?.(host) ?? null, null) : null; + if (!instance || typeof instance !== 'object') { + return fail('The component is no longer on the page, so nothing was restored.'); + } + component = className((instance as { constructor: new () => unknown }).constructor); + field = boundField(ng, instance, wanted.node); + if (!field) { + return fail( + `This signal is not a writable field of ${component} the page could match, so it cannot be restored.`, + ); + } } const entry = history.rawEntry(wanted.node, wanted.epoch); if (!entry.found) { diff --git a/packages/devtools/src/types.ts b/packages/devtools/src/types.ts index df142e0c..32a483f0 100644 --- a/packages/devtools/src/types.ts +++ b/packages/devtools/src/types.ts @@ -51,12 +51,26 @@ export interface SignalGraphNode { * match (see `signal-locator.ts`). */ locator?: SignalWriteLocator; + /** + * On an `effect` node: how many times it ran, and the total and last run + * time in ms. Only where Angular's profiler reports effect runs + * (`effect-runs.ts`); no Angular up to 22.1 does. + */ + runs?: number; + runMs?: number; + lastRunMs?: number; } -/** Where a writable signal lives: the component instance id and its property. */ +/** + * Where a writable signal lives: the component instance id and its property, + * or, with `owner: 'service'`, the environment injector id and the property + * of the service it created (`service` names its class). + */ export interface SignalWriteLocator { id: string; key: string; + owner?: 'service'; + service?: string; } export interface SignalGraphEdge { @@ -83,6 +97,16 @@ export interface SignalWatchReport { stacks: boolean; } +export interface SignalGraphElement { + /** The host's id in the component tree. */ + id: string; + tag: string; + /** The directive classes on the host. */ + directives: string[]; + /** The element injector id from the injector tree, when the graph was asked for by it. */ + injectorId?: string; +} + export interface SignalGraphComponent { id: string; name: string; @@ -125,6 +149,12 @@ export interface SignalGraph { component?: SignalGraphComponent; /** Set instead of `component` when the graph belongs to an environment injector. */ injector?: SignalGraphInjector; + /** + * The element injector the graph was read from: set for a host with + * directives but no component (then without `component`), and for a graph + * asked for by an element injector id from the injector tree (`injectorId`). + */ + element?: SignalGraphElement; /** Environment injectors (root and route) the page can report a graph for. */ environments?: SignalGraphInjector[]; resources?: SignalResource[]; @@ -350,6 +380,12 @@ export interface InjectorInfo { host?: string; /** Set by the server: one of Angular's own injectors (see `framework-tokens.ts`). */ framework?: boolean; + /** + * On an environment injector: a lazy route's injector the router already + * created that no rendered element's lookup path goes through, such as + * the route the app navigated away from. + */ + inactive?: boolean; } export interface ProviderInfo { @@ -403,9 +439,23 @@ export interface InjectorTreeReport { roots: InjectorTreeNode[]; environment: InjectorTreeNode[]; truncated?: boolean; + /** + * Set by the server with `truncated`: the caps that cut the tree, so the + * panel and agents name the real numbers. + */ + limits?: InjectorTreeLimits; zone?: ZoneMode; } +export interface InjectorTreeLimits { + /** Most element injectors a page reports. */ + elements: number; + /** Most injectors in all, element and environment. */ + injectors: number; + /** Deepest an injector can sit before it is left out. */ + depth: number; +} + export interface InjectorPage extends InjectorTreeReport { pageId: string; reportedAt: number; diff --git a/scripts/e2e-mcp.mjs b/scripts/e2e-mcp.mjs index f8e3ff94..e3cfbbd9 100644 --- a/scripts/e2e-mcp.mjs +++ b/scripts/e2e-mcp.mjs @@ -3057,6 +3057,8 @@ function runDocs(state) { 'reset', 'pause', 'block', + 'element', + 'locator', ]); // Library tools are documented but not listed for the demo, which uses none of the libraries. docs.phantomTools = [...documented].filter(