diff --git a/app/src/__tests__/cd-cycle-chart.test.ts b/app/src/__tests__/cd-cycle-chart.test.ts
index 24c6bca2..5713f7c8 100644
--- a/app/src/__tests__/cd-cycle-chart.test.ts
+++ b/app/src/__tests__/cd-cycle-chart.test.ts
@@ -342,8 +342,8 @@ describe('CdRecording with the chart', () => {
await fixture.whenStable();
const host = fixture.nativeElement as HTMLElement;
const table = () =>
- [...host.querySelectorAll('.cd-grid table tbody tr')].map(
- (tr) => tr.querySelector('td')!.textContent,
+ [...host.querySelectorAll('.cd-grid table:first-of-type tbody tr')].map((tr) =>
+ tr.querySelector('td')!.textContent!.trim(),
);
expect(host.querySelector('app-cd-cycle-chart')).not.toBeNull();
expect(table()).toEqual(['List', 'Cart']);
diff --git a/app/src/__tests__/component-detail-actions.test.ts b/app/src/__tests__/component-detail-actions.test.ts
new file mode 100644
index 00000000..519f36ac
--- /dev/null
+++ b/app/src/__tests__/component-detail-actions.test.ts
@@ -0,0 +1,216 @@
+import { TestBed, type ComponentFixture } from '@angular/core/testing';
+import type { DevframeRpcClient } from 'devframe/client';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { ComponentTree } from '../pages/component-tree';
+import { componentJson, hostPathText } from '../pages/component-copy';
+import { ValueFlash, changedKeys } from '../pages/component-value-flash';
+import type { SignalFocus } from '../types/signal-focus.types';
+
+function detail(count: number) {
+ return {
+ id: 'c1',
+ name: 'Cart',
+ tag: 'app-cart',
+ path: 'app-root > app-cart',
+ selector: 'body:nth-child(2) > app-root:nth-child(1) > app-cart:nth-child(1)',
+ inputs: [{ name: 'title', prop: 'title', value: 'Basket' }],
+ outputs: [{ name: 'checkout', prop: 'checkout', listened: true }],
+ properties: [
+ { name: 'count', prop: 'count', value: count, kind: 'signal' },
+ { name: 'token', prop: 'token', value: '[redacted]' },
+ ],
+ listeners: [],
+ directives: [],
+ dependencies: [
+ { from: 'c1', token: 'CartStore', flags: [], providedBy: 'r', providedByName: 'Root' },
+ ],
+ };
+}
+
+function page(count: number) {
+ return {
+ pageId: 'p1',
+ roots: [{ id: 'c1', name: 'Cart', tag: 'app-cart', children: [] }],
+ count: 1,
+ detail: detail(count),
+ reportedAt: 1000,
+ deferBlocks: [
+ {
+ id: 'd1',
+ owner: { id: 'c1', name: 'Cart', tag: 'app-cart' },
+ state: 'placeholder',
+ hydration: 'not-configured',
+ triggers: ['on viewport'],
+ rootIds: [],
+ },
+ ],
+ };
+}
+
+function client() {
+ const listeners: ((value: unknown) => void)[] = [];
+ let value: unknown = { pages: { p1: page(1) } };
+ const rpc = {
+ call: () => Promise.resolve([]),
+ callEvent: () => Promise.resolve(),
+ sharedState: (name: string) =>
+ Promise.resolve({
+ value: () => (name === 'component-tree' ? value : null),
+ on: (_event: string, listener: (value: unknown) => void) => {
+ if (name === 'component-tree') listeners.push(listener);
+ return () => {};
+ },
+ }),
+ };
+ const push = (next: unknown) => {
+ value = next;
+ for (const listener of listeners) listener(next);
+ };
+ const rpcClient = { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
+ return { rpcClient, push };
+}
+
+async function settle(fixture: ComponentFixture) {
+ for (let i = 0; i < 3; i++) {
+ await new Promise((resolve) => setTimeout(resolve));
+ await fixture.whenStable();
+ }
+}
+
+async function selected() {
+ const fixture = TestBed.createComponent(ComponentTree);
+ const { rpcClient, push } = client();
+ fixture.componentRef.setInput('rpc', rpcClient);
+ fixture.componentRef.setInput('canShowSignals', true);
+ await settle(fixture);
+ fixture.componentInstance.selectedId.set('c1');
+ await settle(fixture);
+ return { fixture, push, el: fixture.nativeElement as HTMLElement };
+}
+
+function button(el: HTMLElement, text: string): HTMLButtonElement {
+ const found = Array.from(el.querySelectorAll('button')).find((b) =>
+ b.textContent?.replace(/\s+/g, ' ').trim().startsWith(text),
+ );
+ if (!found) throw new Error(`no button ${text}`);
+ return found;
+}
+
+describe('component detail actions', () => {
+ afterEach(() => {
+ document.body.innerHTML = '';
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ });
+
+ it('copies the detail as JSON and the host selector', async () => {
+ const writeText = vi.fn((_text: string) => Promise.resolve());
+ Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
+ const { fixture, el } = await selected();
+ button(el, 'Copy as JSON').click();
+ await settle(fixture);
+ const json = JSON.parse(writeText.mock.calls[0]![0]);
+ expect(json).toMatchObject({
+ component: 'Cart',
+ hostPath: 'app-root > app-cart',
+ inputs: { title: 'Basket' },
+ outputs: [{ name: 'checkout', listened: true }],
+ properties: { count: 1, token: '[redacted]' },
+ injected: [{ token: 'CartStore', providedBy: 'Root' }],
+ });
+ expect(el.textContent).toContain('Copied Cart as JSON.');
+ button(el, 'Copy host path').click();
+ await settle(fixture);
+ expect(writeText).toHaveBeenLastCalledWith(detail(1).selector);
+ expect(el.textContent).toContain('Copied the host selector.');
+ });
+
+ it('says so when the clipboard refuses', async () => {
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText: () => Promise.reject(new Error('denied')) },
+ configurable: true,
+ });
+ const { fixture, el } = await selected();
+ button(el, 'Copy host path').click();
+ await settle(fixture);
+ expect(el.textContent).toContain('The clipboard is not available here.');
+ });
+
+ it('outlines a value that changed in the last push', async () => {
+ const { fixture, push, el } = await selected();
+ const rows = () => Array.from(el.querySelectorAll('.prop.changed')).map((r) => r.textContent);
+ expect(rows()).toEqual([]);
+ push({ pages: { p1: page(2) } });
+ await settle(fixture);
+ expect(rows()).toHaveLength(1);
+ expect(rows()[0]).toContain('count');
+ });
+
+ it('opens a signal property in the Signals tab', async () => {
+ const { fixture, el } = await selected();
+ const opened: SignalFocus[] = [];
+ fixture.componentInstance.showSignals.subscribe((focus) => opened.push(focus));
+ const link = el.querySelector('.signal-link')!;
+ expect(link.textContent).toContain('Show in Signals');
+ link.click();
+ button(el, 'Show in Signals').click();
+ expect(opened).toEqual([
+ { componentId: 'c1', pageId: 'p1', key: 'count' },
+ { componentId: 'c1', pageId: 'p1' },
+ ]);
+ });
+
+ it("lists the component's own defer blocks", async () => {
+ const { el } = await selected();
+ const heading = Array.from(el.querySelectorAll('.detail h3')).find((h) =>
+ h.textContent?.includes('Defer blocks'),
+ );
+ expect(heading?.parentElement?.textContent).toContain('on viewport');
+ });
+});
+
+describe('component copy text', () => {
+ it('falls back to the component path without a selector', () => {
+ expect(hostPathText({ path: 'Page > Label' })).toBe('Page > Label');
+ expect(hostPathText({ path: 'a > b', selector: 'a > b:nth-child(2)' })).toBe(
+ 'a > b:nth-child(2)',
+ );
+ });
+
+ it('keeps the masked values the page sent', () => {
+ const text = componentJson({ ...detail(1), selector: undefined });
+ expect(JSON.parse(text).properties.token).toBe('[redacted]');
+ expect(JSON.parse(text).selector).toBeUndefined();
+ });
+});
+
+describe('ValueFlash', () => {
+ it('flashes changed keys for a second, and starts over for another component', () => {
+ vi.useFakeTimers();
+ const flash = new ValueFlash();
+ flash.update('p1|c1', new Map([['input:a', 1]]));
+ flash.update('p1|c1', new Map([['input:a', 2]]));
+ expect([...flash.changed()]).toEqual(['input:a']);
+ vi.advanceTimersByTime(999);
+ expect(flash.changed().size).toBe(1);
+ vi.advanceTimersByTime(1);
+ expect(flash.changed().size).toBe(0);
+ flash.update('p1|c2', new Map([['input:a', 3]]));
+ expect(flash.changed().size).toBe(0);
+ });
+
+ it('does not count a new or a removed key as a change', () => {
+ expect(
+ changedKeys(
+ new Map([
+ ['a', '1'],
+ ['b', '2'],
+ ]),
+ new Map([
+ ['a', '1'],
+ ['c', '3'],
+ ]),
+ ),
+ ).toEqual([]);
+ });
+});
diff --git a/app/src/__tests__/component-pick-keys.test.ts b/app/src/__tests__/component-pick-keys.test.ts
new file mode 100644
index 00000000..816f97e1
--- /dev/null
+++ b/app/src/__tests__/component-pick-keys.test.ts
@@ -0,0 +1,74 @@
+import { TestBed, type ComponentFixture } from '@angular/core/testing';
+import type { DevframeRpcClient } from 'devframe/client';
+import { afterEach, describe, expect, it } from 'vitest';
+import { ComponentTree } from '../pages/component-tree';
+
+const page = {
+ pageId: 'p1',
+ roots: [{ id: 'c1', name: 'App', tag: 'app-root', children: [] }],
+ count: 1,
+ detail: null,
+ reportedAt: 1000,
+};
+
+function client(calls: { name: string; args: unknown }[]) {
+ const rpc = {
+ call: (name: string, args: unknown) => {
+ calls.push({ name, args });
+ if (name === 'request-component-pick') return new Promise(() => {});
+ if (name === 'request-component-pick-move') {
+ return Promise.resolve({ moved: true, id: 'c1', name: 'App', tag: 'app-root' });
+ }
+ return Promise.resolve([]);
+ },
+ callEvent: () => Promise.resolve(),
+ sharedState: (name: string) =>
+ Promise.resolve({
+ value: () => (name === 'component-tree' ? { pages: { p1: page } } : null),
+ 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();
+ }
+}
+
+describe('keyboard pick', () => {
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('sends the arrow keys and Enter to the page while picking, and announces the component', async () => {
+ const calls: { name: string; args: unknown }[] = [];
+ const fixture = TestBed.createComponent(ComponentTree);
+ fixture.componentRef.setInput('rpc', client(calls));
+ await settle(fixture);
+ const el = fixture.nativeElement as HTMLElement;
+ const press = (key: string) => {
+ const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
+ el.querySelector('.pick')!.dispatchEvent(event);
+ return event;
+ };
+ expect(press('ArrowDown').defaultPrevented).toBe(false);
+ void fixture.componentInstance.pick();
+ await settle(fixture);
+ expect(press('ArrowDown').defaultPrevented).toBe(true);
+ await settle(fixture);
+ expect(calls.filter((c) => c.name === 'request-component-pick-move')).toEqual([
+ { name: 'request-component-pick-move', args: { pageId: 'p1', key: 'next' } },
+ ]);
+ expect(fixture.componentInstance.announcement()).toBe(
+ 'App . Press Enter to pick it.',
+ );
+ expect(press('Enter').defaultPrevented).toBe(true);
+ expect(calls.at(-1)).toEqual({
+ name: 'request-component-pick-move',
+ args: { pageId: 'p1', key: 'pick' },
+ });
+ });
+});
diff --git a/app/src/__tests__/component-tree-defer.test.ts b/app/src/__tests__/component-tree-defer.test.ts
index 3e3b2871..a48d95c1 100644
--- a/app/src/__tests__/component-tree-defer.test.ts
+++ b/app/src/__tests__/component-tree-defer.test.ts
@@ -58,6 +58,39 @@ describe('defer blocks', () => {
expect(badge.classList.contains('defer-state-complete')).toBe(true);
});
+ it('says when a block hydrated before the devtools attached', async () => {
+ const fixture = TestBed.createComponent(ComponentTree);
+ const block = (id: string, hydratedAt?: number) => ({
+ id,
+ state: 'complete',
+ hydration: 'hydrated',
+ triggers: ['hydrate on interaction'],
+ rootIds: [],
+ ...(hydratedAt ? { hydratedAt } : {}),
+ });
+ const page = {
+ pageId: 'p1',
+ roots: [{ id: 'c1', name: 'App', tag: 'app-root', children: [] }],
+ count: 1,
+ detail: null,
+ reportedAt: 1000,
+ deferBlocks: [block('d1'), block('d2', 900)],
+ };
+ fixture.componentRef.setInput('rpc', client({ p1: page }));
+ for (let i = 0; i < 3; i++) {
+ await new Promise((resolve) => setTimeout(resolve));
+ await fixture.whenStable();
+ }
+ const flags = Array.from(
+ (fixture.nativeElement as HTMLElement).querySelectorAll('.defer-row'),
+ ).map((row) =>
+ Array.from(row.querySelectorAll('.flag')).map((flag) => flag.textContent?.trim()),
+ );
+ expect(flags[0]).toContain('hydrated before inspection');
+ expect(flags[1]).toContain('hydrated');
+ expect(flags[1]).not.toContain('hydrated before inspection');
+ });
+
it('keeps a row without an owner focusable, marked aria-disabled', async () => {
const fixture = TestBed.createComponent(ComponentTree);
const page = {
@@ -232,3 +265,62 @@ describe('defer blocks', () => {
fixture.destroy();
});
});
+
+describe('defer block details', () => {
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('open below the row with the block id, transitions and mismatches inside it', async () => {
+ const fixture = TestBed.createComponent(ComponentTree);
+ const page = {
+ pageId: 'p1',
+ roots: [{ id: 'c1', name: 'App', tag: 'app-root', children: [] }],
+ count: 1,
+ detail: null,
+ reportedAt: 1000,
+ hydration: {
+ hydrated: true,
+ counts: { hydrated: 1, skipped: 0, mismatched: 1, clientOnly: 0, dehydratedBlocks: 0 },
+ mismatches: [{ id: 'e9', name: 'Note', tag: 'app-note' }],
+ },
+ deferBlocks: [
+ {
+ id: 'd4',
+ owner: { id: 'c1', name: 'App', tag: 'app-root' },
+ state: 'complete',
+ hydration: 'not-configured',
+ triggers: ['on idle'],
+ rootIds: ['e9'],
+ since: 2000,
+ transitions: [
+ { state: 'placeholder', hydration: 'not-configured', at: 1000 },
+ { state: 'complete', hydration: 'not-configured', at: 2000 },
+ ],
+ },
+ ],
+ };
+ fixture.componentRef.setInput('rpc', client({ p1: page }));
+ for (let i = 0; i < 3; i++) {
+ await new Promise((resolve) => setTimeout(resolve));
+ await fixture.whenStable();
+ }
+ const el = fixture.nativeElement as HTMLElement;
+ expect(el.querySelector('.defer-row')!.textContent).toContain('mismatch');
+ const toggle = el.querySelector('.defer-toggle')!;
+ expect(toggle.getAttribute('aria-expanded')).toBe('false');
+ expect(el.querySelector('.defer-detail')).toBeNull();
+ toggle.click();
+ await fixture.whenStable();
+ expect(toggle.getAttribute('aria-expanded')).toBe('true');
+ const detail = el.querySelector('.defer-detail')!;
+ expect(toggle.getAttribute('aria-controls')).toBe(detail.id);
+ expect(detail.textContent).toContain('d4');
+ expect(Array.from(detail.querySelectorAll('.transitions .mono'), (s) => s.textContent)).toEqual(
+ ['placeholder', 'complete'],
+ );
+ const revealed = vi.spyOn(fixture.componentInstance, 'revealNode');
+ detail.querySelector('.mismatch-links button')!.click();
+ expect(revealed).toHaveBeenCalledWith('e9');
+ });
+});
diff --git a/app/src/__tests__/component-tree-directive-source.test.ts b/app/src/__tests__/component-tree-directive-source.test.ts
new file mode 100644
index 00000000..cc651f3c
--- /dev/null
+++ b/app/src/__tests__/component-tree-directive-source.test.ts
@@ -0,0 +1,87 @@
+import { TestBed, type ComponentFixture } from '@angular/core/testing';
+import type { DevframeRpcClient } from 'devframe/client';
+import { afterEach, describe, expect, it } from 'vitest';
+import { ComponentTree } from '../pages/component-tree';
+
+const directive = (className: string, file: string) => ({
+ selector: '[appTooltip]',
+ className,
+ kind: 'directive',
+ file,
+ line: 7,
+ inputs: [],
+ outputs: [],
+ isStandalone: true,
+});
+
+function fakeClient(components: unknown[]): DevframeRpcClient {
+ const page = {
+ pageId: 'p1',
+ roots: [
+ {
+ id: 'c1',
+ name: 'Card',
+ tag: 'app-card',
+ children: [
+ {
+ id: 'h1',
+ name: 'button',
+ tag: 'button',
+ kind: 'directive',
+ directives: ['Tooltip', 'Ripple'],
+ children: [],
+ },
+ ],
+ },
+ ],
+ count: 1,
+ directiveHostCount: 1,
+ detail: null,
+ reportedAt: 1000,
+ };
+ const rpc = {
+ call: (name: string) => Promise.resolve(name === 'get-components' ? components : []),
+ callEvent: () => Promise.resolve(),
+ sharedState: (name: string) =>
+ Promise.resolve({
+ value: () => (name === 'component-tree' ? { pages: { p1: page } } : null),
+ 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();
+ }
+}
+
+describe('directive sources', () => {
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('marks a directive file found by class name as likely, and names several files', async () => {
+ const fixture = TestBed.createComponent(ComponentTree);
+ fixture.componentRef.setInput(
+ 'rpc',
+ fakeClient([
+ directive('Tooltip', 'src/app/tooltip.directive.ts'),
+ directive('Ripple', 'src/app/a/ripple.ts'),
+ directive('Ripple', 'src/app/b/ripple.ts'),
+ ]),
+ );
+ await settle(fixture);
+ fixture.componentInstance.select('h1');
+ await settle(fixture);
+ const chips = Array.from(
+ (fixture.nativeElement as HTMLElement).querySelectorAll('.detail .chip'),
+ ).map((chip) => chip.textContent?.replace(/\s+/g, ' ').trim());
+ expect(chips[0]).toContain('Tooltip');
+ expect(chips[0]).toContain('Likely');
+ expect(chips[0]).toContain('tooltip.directive.ts');
+ expect(chips[1]).toContain('2 source files define Ripple');
+ });
+});
diff --git a/app/src/__tests__/duplicate-track-keys.test.ts b/app/src/__tests__/duplicate-track-keys.test.ts
index bc950f37..c5cecdc8 100644
--- a/app/src/__tests__/duplicate-track-keys.test.ts
+++ b/app/src/__tests__/duplicate-track-keys.test.ts
@@ -61,11 +61,17 @@ describe('lists with repeated keys', () => {
await settle(fixture);
fixture.componentRef.setInput('page', cdPage([2, 7]));
await settle(fixture);
- const rows = Array.from(
- (fixture.nativeElement as HTMLElement).querySelectorAll('tbody tr'),
- (row) => Array.from(row.querySelectorAll('td'), (cell) => cell.textContent?.trim()).join('|'),
- );
- expect(rows).toEqual(['ItemComponent|1|7.00 ms|7.00 ms', 'ItemComponent|1|2.00 ms|2.00 ms']);
+ const rows = (table: number) =>
+ Array.from(
+ (fixture.nativeElement as HTMLElement)
+ .querySelectorAll('.cd-grid table')
+ [table]!.querySelectorAll('tbody tr'),
+ (row) =>
+ Array.from(row.querySelectorAll('td'), (cell) => cell.textContent?.trim()).join('|'),
+ );
+ expect(rows(0)).toEqual(['ItemComponent|1|7.00 ms|7.00 ms', 'ItemComponent|1|2.00 ms|2.00 ms']);
+ // The "Most often checked" table lists the same two classes.
+ expect(rows(1)).toEqual(['ItemComponent|1|1|7.00 ms', 'ItemComponent|1|1|2.00 ms']);
expect(logs(spies)).not.toContain('NG0955');
});
@@ -87,3 +93,29 @@ describe('lists with repeated keys', () => {
expect(logs(spies)).not.toContain('NG0955');
});
});
+
+describe('change detection component tables', () => {
+ it('lists the most checked classes and selects one from its name', async () => {
+ const fixture = TestBed.createComponent(CdRecording);
+ const stat = (name: string, checks: number, ms: number) => ({
+ name,
+ checks,
+ ms,
+ maxMs: ms,
+ cycles: checks,
+ });
+ fixture.componentRef.setInput('page', {
+ ...cdPage([1, 1]),
+ components: [stat('Slow', 1, 9), stat('Busy', 12, 1)],
+ });
+ await settle(fixture);
+ const shown: string[] = [];
+ fixture.componentInstance.showComponent.subscribe((name) => shown.push(name));
+ const tables = (fixture.nativeElement as HTMLElement).querySelectorAll('.cd-grid table');
+ const first = (table: Element) => table.querySelector('tbody .name-link')!;
+ expect(first(tables[0]!).textContent?.trim()).toBe('Slow');
+ expect(first(tables[1]!).textContent?.trim()).toBe('Busy');
+ (first(tables[1]!) as HTMLButtonElement).click();
+ expect(shown).toEqual(['Busy']);
+ });
+});
diff --git a/app/src/__tests__/shell-deep-link.test.ts b/app/src/__tests__/shell-deep-link.test.ts
index 6d588d53..bb2431ca 100644
--- a/app/src/__tests__/shell-deep-link.test.ts
+++ b/app/src/__tests__/shell-deep-link.test.ts
@@ -81,9 +81,21 @@ describe('deep links', () => {
fixture.destroy();
});
+ it('selects the signal graph of the component a signals link names', async () => {
+ const { fixture, app } = await boot('#tab=signals&id=c3&page=p1');
+ expect(app.tab()).toBe('signals');
+ expect(app.signalFocus()).toEqual({ componentId: 'c3', pageId: 'p1' });
+ fixture.destroy();
+ });
+
it('opens a tab without an item, and ignores an unknown tab', async () => {
- let booted = await boot('#tab=signals&id=3');
+ let booted = await boot('#tab=dashboard&id=3');
+ expect(booted.app.tab()).toBe('dashboard');
+ booted.fixture.destroy();
+ TestBed.resetTestingModule();
+ booted = await boot('#tab=signals');
expect(booted.app.tab()).toBe('signals');
+ expect(booted.app.signalFocus()).toBeNull();
booted.fixture.destroy();
TestBed.resetTestingModule();
booted = await boot('#tab=nope&id=3');
diff --git a/app/src/__tests__/signal-focus.test.ts b/app/src/__tests__/signal-focus.test.ts
new file mode 100644
index 00000000..5c800e38
--- /dev/null
+++ b/app/src/__tests__/signal-focus.test.ts
@@ -0,0 +1,81 @@
+import { TestBed, type ComponentFixture } from '@angular/core/testing';
+import type { DevframeRpcClient } from 'devframe/client';
+import { afterEach, describe, expect, it } from 'vitest';
+import { SignalInspector } from '../pages/signal-inspector';
+
+const graphOf = (componentId: string) => ({
+ pageId: 'p1',
+ component: { id: componentId, name: 'Cart', tag: 'app-cart', path: 'app-cart' },
+ nodes: [
+ {
+ id: '1',
+ kind: 'signal',
+ label: 'count',
+ epoch: 1,
+ value: 1,
+ locator: { id: componentId, key: 'count' },
+ },
+ { id: '2', kind: 'computed', label: 'doubled', epoch: 1, value: 2 },
+ ],
+ edges: [{ consumer: 1, producer: 0 }],
+});
+
+function fakeClient() {
+ const calls: { name: string; args: unknown }[] = [];
+ const listeners: ((value: unknown) => void)[] = [];
+ let state: unknown = { graph: graphOf('c0'), pages: { p1: graphOf('c0') } };
+ const rpc = {
+ call: (name: string, args: unknown) => {
+ calls.push({ name, args });
+ return Promise.resolve([]);
+ },
+ sharedState: (name: string) =>
+ Promise.resolve({
+ value: () => (name === 'signal-graph' ? state : {}),
+ on: (_event: string, listener: (value: unknown) => void) => {
+ if (name === 'signal-graph') listeners.push(listener);
+ return () => {};
+ },
+ }),
+ };
+ const push = (next: unknown) => {
+ state = next;
+ for (const listener of listeners) listener(next);
+ };
+ const client = { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
+ return { client, calls, push };
+}
+
+async function settle(fixture: ComponentFixture) {
+ for (let i = 0; i < 3; i++) {
+ await new Promise((resolve) => setTimeout(resolve));
+ await fixture.whenStable();
+ }
+}
+
+afterEach(() => {
+ document.body.innerHTML = '';
+});
+
+describe('SignalInspector focus', () => {
+ it('asks the page for the focused component and selects the signal once its graph arrives', async () => {
+ const fixture = TestBed.createComponent(SignalInspector);
+ const { client, calls, push } = fakeClient();
+ fixture.componentRef.setInput('rpc', client);
+ document.body.append(fixture.nativeElement);
+ await settle(fixture);
+
+ fixture.componentRef.setInput('focus', { componentId: 'c1', pageId: 'p1', key: 'count' });
+ await settle(fixture);
+ expect(calls).toContainEqual({
+ name: 'select-signal-target',
+ args: { pageId: 'p1', id: 'c1' },
+ });
+ // The graph of another component does not select anything.
+ expect(fixture.componentInstance.selectedId()).toBeNull();
+
+ push({ graph: graphOf('c1'), pages: { p1: graphOf('c1') } });
+ await settle(fixture);
+ expect(fixture.componentInstance.selectedId()).toBe('1');
+ });
+});
diff --git a/app/src/app.ts b/app/src/app.ts
index b23a087e..cdc727ce 100644
--- a/app/src/app.ts
+++ b/app/src/app.ts
@@ -27,6 +27,7 @@ import { PipesInspector } from './pages/pipes-inspector';
import type { Tab, Tabs } from './types/tab.types';
import type { ComponentFocus } from './types/component-focus.types';
import type { InjectorFocus } from './types/injector-focus.types';
+import type { SignalFocus } from './types/signal-focus.types';
import { AnalogInspector } from './pages/analog-inspector';
import { NetworkInspector } from './pages/network-inspector';
import { ComingSoon, type ComingSoonInfo } from './pages/coming-soon';
@@ -544,11 +545,13 @@ function readView(): View | null {
[focus]="componentFocus()"
[command]="componentCommand()"
[canShowInjector]="config().inspectors.injectors"
+ [canShowSignals]="config().inspectors.signals"
[canShowPipes]="config().inspectors.pipes"
(focusHandled)="componentFocus.set(null)"
(commandHandled)="componentCommand.set(null)"
(showForm)="showForm($event)"
(showInjector)="showInjector($event)"
+ (showSignals)="showSignals($event)"
(showPipe)="showPipe($event)"
/>
}
@@ -556,7 +559,7 @@ function readView(): View | null {
}
@case ('signals') {
-
+
}
@case ('injectors') {
null,
});
+ // Belongs to the Signals tab that received it, like `componentFocus`.
+ readonly signalFocus = linkedSignal({
+ source: this.tab,
+ computation: () => null,
+ });
+
+ /**
+ * Opens the Signals tab on a component's graph, through the same deep link
+ * a `#tab=signals&id=` hash opens, then selects the signal `key` names.
+ */
+ showSignals(focus: SignalFocus) {
+ if (!this.config().inspectors.signals) return;
+ const page = focus.pageId ? { page: focus.pageId } : {};
+ if (!this.openLink({ tab: 'signals', id: focus.componentId, ...page })) return;
+ this.signalFocus.set(focus);
+ }
+
showInjector(focus: InjectorFocus) {
if (!this.config().inspectors.injectors) return;
this.switchTab('injectors');
diff --git a/app/src/pages/cd-recording.ts b/app/src/pages/cd-recording.ts
index 5015753a..aad1d2e8 100644
--- a/app/src/pages/cd-recording.ts
+++ b/app/src/pages/cd-recording.ts
@@ -94,6 +94,9 @@ const SHOWN_CYCLES = 30;
of it stops with the recording.
@if (native()) {
An app on a device has no DOM, so checks that changed nothing are not counted there.
+ } @else {
+ A recording keeps running when the tab reloads, so it catches the next start of the app.
+ The cycles from before the reload are gone with the old page.
}
@if (page()?.supported === false) {
@@ -213,7 +216,11 @@ const SHOWN_CYCLES = 30;
+ Select a component name to show its most checked instance in the tree.
+
Latest cycles
@@ -390,6 +432,26 @@ const SHOWN_CYCLES = 30;
td.mono {
overflow-wrap: anywhere;
}
+ .name-link {
+ height: auto;
+ min-height: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--text-strong);
+ font: inherit;
+ text-align: left;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ cursor: pointer;
+ }
+ .name-link:hover {
+ background: none;
+ text-decoration-thickness: 2px;
+ }
+ .name-link:focus-visible {
+ @include m.focus-ring(-2px);
+ }
.num {
text-align: right;
font-variant-numeric: tabular-nums;
@@ -506,6 +568,10 @@ export class CdRecording {
: `cycles ${picked[0].id} to ${picked.at(-1)!.id}`;
});
readonly slowest = computed(() => this.stats().slice(0, SHOWN));
+ /** The classes Angular checked most often, as the change-detection tool lists them. */
+ readonly busiest = computed(() =>
+ [...this.stats()].sort((a, b) => b.checks - a.checks).slice(0, SHOWN),
+ );
readonly latest = computed(() => this.cycles().slice(-SHOWN_CYCLES).reverse());
/** Says when the recording checked more classes or instances than it keeps. */
readonly capNotes = computed(() => {
diff --git a/app/src/pages/component-copy.ts b/app/src/pages/component-copy.ts
new file mode 100644
index 00000000..9894baa0
--- /dev/null
+++ b/app/src/pages/component-copy.ts
@@ -0,0 +1,78 @@
+/** The fields of a component detail the copy actions read. */
+export interface CopyProp {
+ name: string;
+ value?: unknown;
+ listened?: boolean;
+ kind?: string;
+}
+
+export interface CopyDetail {
+ name: string;
+ tag: string;
+ path: string;
+ selector?: string;
+ source?: { file: string; line: number };
+ changeDetection?: string;
+ encapsulation?: string;
+ inputs: CopyProp[];
+ outputs: CopyProp[];
+ properties?: CopyProp[];
+ listeners: string[];
+ directives: { name: string; inputs: CopyProp[]; outputs: CopyProp[] }[];
+ dependencies: { token: string; providedByName?: string; providedBy: string | null }[];
+}
+
+function valuesOf(props: readonly CopyProp[]): Record
{
+ return Object.fromEntries(props.map((p) => [p.name, p.value ?? null]));
+}
+
+function outputsOf(props: readonly CopyProp[]): { name: string; listened: boolean }[] {
+ return props.map((p) => ({ name: p.name, listened: !!p.listened }));
+}
+
+/**
+ * The selected component as JSON for "Copy as JSON". It holds only what the
+ * panel already has: the page serialized and masked every value before it
+ * sent it (`serialize` and the redaction settings), so the copy carries the
+ * same `[redacted]` markers and cut levels as the detail on screen.
+ */
+export function componentJson(detail: CopyDetail): string {
+ const out: Record = {
+ component: detail.name,
+ tag: detail.tag,
+ hostPath: detail.path,
+ };
+ if (detail.selector) out['selector'] = detail.selector;
+ if (detail.source) out['source'] = detail.source;
+ if (detail.changeDetection) out['changeDetection'] = detail.changeDetection;
+ if (detail.encapsulation) out['encapsulation'] = detail.encapsulation;
+ out['inputs'] = valuesOf(detail.inputs);
+ out['outputs'] = outputsOf(detail.outputs);
+ out['properties'] = valuesOf(detail.properties ?? []);
+ if (detail.listeners.length) out['listeners'] = detail.listeners;
+ if (detail.directives.length) {
+ out['directives'] = detail.directives.map((dir) => ({
+ name: dir.name,
+ inputs: valuesOf(dir.inputs),
+ outputs: outputsOf(dir.outputs),
+ }));
+ }
+ out['injected'] = detail.dependencies.map((dep) => ({
+ token: dep.token,
+ providedBy: dep.providedBy ? (dep.providedByName ?? 'an injector') : null,
+ }));
+ try {
+ return JSON.stringify(out, null, 2);
+ } catch {
+ return JSON.stringify({ component: detail.name, tag: detail.tag, hostPath: detail.path });
+ }
+}
+
+/**
+ * The text "Copy host path" copies: a CSS selector that finds the host with
+ * `document.querySelector`, or the component path (`app-root > app-list`)
+ * from a page that sends no selector, such as an app on a device.
+ */
+export function hostPathText(detail: Pick): string {
+ return detail.selector || detail.path;
+}
diff --git a/app/src/pages/component-tree.ts b/app/src/pages/component-tree.ts
index 15e33526..c5888930 100644
--- a/app/src/pages/component-tree.ts
+++ b/app/src/pages/component-tree.ts
@@ -14,6 +14,7 @@ import {
untracked,
viewChild,
} from '@angular/core';
+import { NgTemplateOutlet } from '@angular/common';
import type { DevframeRpcClient } from 'devframe/client';
import { ExtensionBridge, type SourceLocation } from '../extension-bridge';
import { hostPageId } from '../page-id';
@@ -45,6 +46,9 @@ import { CdRecording, type CdPage } from './cd-recording';
import type { ComponentFocus } from '../types/component-focus.types';
import type { ComponentCommand } from '../types/component-command.types';
import type { InjectorFocus } from '../types/injector-focus.types';
+import type { SignalFocus } from '../types/signal-focus.types';
+import { componentJson, hostPathText } from './component-copy';
+import { ValueFlash, flashKey } from './component-value-flash';
import { SourceLink } from '../ui/source-link';
import { actionAllowed } from '../devtools-config';
import { ValueTree } from './component-value-tree';
@@ -104,6 +108,8 @@ interface Detail {
name: string;
tag: string;
path: string;
+ /** A CSS selector that finds the host with `querySelector`, from a browser page. */
+ selector?: string;
source?: SourceLocation;
changeDetection?: string;
encapsulation?: string;
@@ -197,6 +203,15 @@ export function ancestors(parents: ReadonlyMap, id: string): str
return out;
}
+/** The keys that move a running pick on the page, as the page names them. */
+const PICK_KEY_NAMES: Record = {
+ ArrowDown: 'next',
+ ArrowUp: 'previous',
+ ArrowLeft: 'parent',
+ ArrowRight: 'child',
+ Enter: 'pick',
+};
+
const DIRECTIVE_HOSTS_KEY = 'pangular:components:directive-hosts';
/** Whether this viewer last chose to show directive hosts; shown unless they hid them. */
@@ -211,6 +226,7 @@ function storedDirectiveHosts(): boolean {
@Component({
selector: 'app-component-tree',
imports: [
+ NgTemplateOutlet,
Select,
CdRecording,
SourceLink,
@@ -223,7 +239,7 @@ function storedDirectiveHosts(): boolean {
AngularUnavailable,
ComponentPipesSection,
],
- host: { '(keydown.escape)': 'cancelPick()' },
+ host: { '(keydown.escape)': 'cancelPick()', '(keydown)': 'pickKey($event)' },
template: `
@if (live()) {
@@ -458,7 +474,13 @@ function storedDirectiveHosts(): boolean {
@for (name of host.directives ?? []; track name + $index) {
- - {{ name }}
+ -
+ {{ name }}
+
+
}
@@ -526,14 +548,29 @@ function storedDirectiveHosts(): boolean {
}
}
- @if (canShowInjector() && !staticReport()) {
+ @if ((canShowInjector() || canShowSignals()) && !staticReport()) {