Skip to content

Commit da549e4

Browse files
committed
feat(components): flash checked components, add a Pangular Performance track and profiling toggle
Adds three live change detection views, toggled from the panel and through new flash, track and angularProfiling arguments on the change-detection tool: a flash overlay over each checked component, a Pangular track in the Chrome Performance panel through console.timeStamp, and ng.enableProfiling(). Each costs nothing while off. Refs #341
1 parent ed19f98 commit da549e4

18 files changed

Lines changed: 1702 additions & 38 deletions

File tree

‎app/src/__tests__/cd-views.test.ts‎

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
2+
import type { DevframeRpcClient } from 'devframe/client';
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { CdRecording, type CdPage } from '../pages/cd-recording';
5+
6+
let fixture: ComponentFixture<CdRecording>;
7+
let rpcCall: ReturnType<typeof vi.fn>;
8+
9+
function page(options?: CdPage['options'], supported = true): CdPage {
10+
return {
11+
pageId: 'p1',
12+
supported,
13+
recording: false,
14+
startedAt: null,
15+
dropped: 0,
16+
cycles: [],
17+
components: [],
18+
hosts: {},
19+
...(options ? { options } : {}),
20+
};
21+
}
22+
23+
const host = () => fixture.nativeElement as HTMLElement;
24+
const live = () => host().querySelector('[role="status"]')!.textContent!;
25+
const toggle = (text: string) =>
26+
[...host().querySelectorAll<HTMLButtonElement>('.views button')].find(
27+
(b) => b.textContent?.trim() === text,
28+
)!;
29+
const pressed = (text: string) => toggle(text).getAttribute('aria-pressed');
30+
31+
async function show(value: CdPage | null) {
32+
fixture.componentRef.setInput('page', value);
33+
await fixture.whenStable();
34+
}
35+
36+
async function click(text: string) {
37+
toggle(text).click();
38+
await fixture.whenStable();
39+
await new Promise((resolve) => setTimeout(resolve));
40+
await fixture.whenStable();
41+
}
42+
43+
describe('CdRecording live views', () => {
44+
beforeEach(async () => {
45+
rpcCall = vi.fn(() => Promise.resolve({ ok: true }));
46+
const client = { scope: () => ({ rpc: { call: rpcCall } }) };
47+
fixture = TestBed.createComponent(CdRecording);
48+
fixture.componentRef.setInput('pageId', 'p1');
49+
fixture.componentRef.setInput('rpc', client as unknown as DevframeRpcClient);
50+
await show(null);
51+
});
52+
53+
afterEach(() => {
54+
fixture?.destroy();
55+
TestBed.resetTestingModule();
56+
});
57+
58+
it('shows the three toggles off in a labelled group', () => {
59+
const group = host().querySelector('.views')!;
60+
expect(group.getAttribute('role')).toBe('group');
61+
expect(host().querySelector(`#${group.getAttribute('aria-labelledby')}`)!.textContent).toBe(
62+
'Live views',
63+
);
64+
expect(pressed('Flash checks')).toBe('false');
65+
expect(pressed('Performance track')).toBe('false');
66+
expect(pressed('Angular profiling')).toBe('false');
67+
for (const button of host().querySelectorAll<HTMLButtonElement>('.views button')) {
68+
expect(button.type).toBe('button');
69+
expect(button.disabled).toBe(false);
70+
}
71+
});
72+
73+
it('turns flash checks on and off for the page and announces it', async () => {
74+
await click('Flash checks');
75+
expect(rpcCall).toHaveBeenLastCalledWith('request-change-detection-options', {
76+
pageId: 'p1',
77+
flash: true,
78+
});
79+
expect(pressed('Flash checks')).toBe('true');
80+
expect(live()).toBe('Flash checks on.');
81+
82+
await show(
83+
page({ flash: true, track: false, angularProfiling: false, angularProfilingAvailable: true }),
84+
);
85+
await click('Flash checks');
86+
expect(rpcCall).toHaveBeenLastCalledWith('request-change-detection-options', {
87+
pageId: 'p1',
88+
flash: false,
89+
});
90+
expect(pressed('Flash checks')).toBe('false');
91+
});
92+
93+
it('turns the Performance track on', async () => {
94+
await click('Performance track');
95+
expect(rpcCall).toHaveBeenLastCalledWith('request-change-detection-options', {
96+
pageId: 'p1',
97+
track: true,
98+
});
99+
expect(live()).toContain('Performance track on.');
100+
});
101+
102+
it('turns Angular profiling on once and says it stays on', async () => {
103+
await click('Angular profiling');
104+
expect(rpcCall).toHaveBeenLastCalledWith('request-change-detection-options', {
105+
pageId: 'p1',
106+
angularProfiling: true,
107+
});
108+
expect(pressed('Angular profiling')).toBe('true');
109+
expect(toggle('Angular profiling').disabled).toBe(true);
110+
expect(host().textContent).toContain('Angular profiling stays on until the page reloads.');
111+
});
112+
113+
it('shows the state the page reports', async () => {
114+
await show(
115+
page({ flash: false, track: true, angularProfiling: true, angularProfilingAvailable: true }),
116+
);
117+
expect(pressed('Performance track')).toBe('true');
118+
expect(pressed('Angular profiling')).toBe('true');
119+
expect(toggle('Angular profiling').disabled).toBe(true);
120+
});
121+
122+
it('explains when the page has no ng.enableProfiling', async () => {
123+
await show(
124+
page({
125+
flash: false,
126+
track: false,
127+
angularProfiling: false,
128+
angularProfilingAvailable: false,
129+
}),
130+
);
131+
expect(toggle('Angular profiling').disabled).toBe(true);
132+
expect(host().textContent).toContain('It needs Angular 20 or later in a development build.');
133+
});
134+
135+
it('puts a toggle back when the page cannot be reached', async () => {
136+
rpcCall.mockImplementation(() => Promise.reject(new Error('offline')));
137+
await click('Flash checks');
138+
expect(pressed('Flash checks')).toBe('false');
139+
expect(live()).toBe('Could not reach the page.');
140+
});
141+
142+
it('disables the toggles on a page that cannot record', async () => {
143+
await show(page(undefined, false));
144+
for (const button of host().querySelectorAll<HTMLButtonElement>('.views button')) {
145+
expect(button.disabled).toBe(true);
146+
}
147+
});
148+
});

‎app/src/pages/cd-recording.ts‎

Lines changed: 107 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { Component, computed, input, output, signal } from '@angular/core';
2-
import type { CdReport } from '@pangular-inspector/devtools/config';
1+
import { Component, computed, input, linkedSignal, output, signal } from '@angular/core';
2+
import type { CdOptions, CdReport } from '@pangular-inspector/devtools/config';
33
import type { DevframeRpcClient } from 'devframe/client';
44
import { panelConfig } from '../devtools-config';
55
import { time } from '../format';
@@ -59,6 +59,57 @@ const SHOWN_CYCLES = 30;
5959
@if (page()?.supported === false) {
6060
<p class="note">This page can't record change detection. It needs Angular 20 or later.</p>
6161
}
62+
@if (!imported()) {
63+
<div class="views" role="group" aria-labelledby="cd-views-label">
64+
<span id="cd-views-label" class="views-label">Live views</span>
65+
<button
66+
type="button"
67+
class="view"
68+
[attr.aria-pressed]="views().flash"
69+
[disabled]="!canShowViews()"
70+
(click)="setView('flash', !views().flash)"
71+
>
72+
<span class="dot" aria-hidden="true"></span>
73+
Flash checks
74+
</button>
75+
<button
76+
type="button"
77+
class="view"
78+
[attr.aria-pressed]="views().track"
79+
[disabled]="!canShowViews()"
80+
(click)="setView('track', !views().track)"
81+
>
82+
<span class="dot" aria-hidden="true"></span>
83+
Performance track
84+
</button>
85+
<button
86+
type="button"
87+
class="view"
88+
[attr.aria-pressed]="views().angularProfiling"
89+
[disabled]="
90+
!canShowViews() || views().angularProfiling || !views().angularProfilingAvailable
91+
"
92+
(click)="setView('angularProfiling', true)"
93+
>
94+
<span class="dot" aria-hidden="true"></span>
95+
Angular profiling
96+
</button>
97+
</div>
98+
<p class="hint">
99+
Flash checks draws a box over each component on the page as Angular checks it, with its
100+
name and how often it was checked, fading over a second. Performance track adds a Pangular
101+
track to the Chrome Performance panel with every component check, hook and output. Angular
102+
profiling turns on Angular's own track there.
103+
</p>
104+
@if (views().angularProfiling) {
105+
<p class="note">Angular profiling stays on until the page reloads.</p>
106+
} @else if (!views().angularProfilingAvailable) {
107+
<p class="note">
108+
This page has no <span class="mono">ng.enableProfiling</span>. It needs Angular 20 or
109+
later in a development build.
110+
</p>
111+
}
112+
}
62113
<app-limit-note
63114
[dropped]="page()?.dropped ?? 0"
64115
[max]="maxCycles()"
@@ -215,6 +266,22 @@ const SHOWN_CYCLES = 30;
215266
cursor: not-allowed;
216267
opacity: 0.6;
217268
}
269+
.views {
270+
display: flex;
271+
flex-wrap: wrap;
272+
align-items: center;
273+
gap: 8px;
274+
margin-top: 8px;
275+
}
276+
.views-label {
277+
@include m.label;
278+
}
279+
button.view[aria-pressed='true'] {
280+
border-color: var(--accent-line);
281+
}
282+
button.view[aria-pressed='true'] .dot {
283+
background: var(--accent);
284+
}
218285
.dot {
219286
width: 8px;
220287
height: 8px;
@@ -329,6 +396,18 @@ export class CdRecording {
329396
/** The recording on screen: an imported one, else the page's. */
330397
readonly page = computed(() => this.imported()?.page ?? this.livePage());
331398

399+
/** The views the page has on, set ahead of the page's next report when a toggle is used. */
400+
readonly views = linkedSignal<CdOptions>(
401+
() =>
402+
this.livePage()?.options ?? {
403+
flash: false,
404+
track: false,
405+
angularProfiling: false,
406+
angularProfilingAvailable: true,
407+
},
408+
);
409+
readonly canShowViews = computed(() => !!this.pageId() && this.livePage()?.supported !== false);
410+
332411
protected readonly message = signal('');
333412
/**
334413
* What a screen reader hears. It changes only when Record, Stop or Clear finishes or fails.
@@ -426,24 +505,47 @@ export class CdRecording {
426505
await this.send({ clear: true }, '', () => 'Cleared the recorded cycles.');
427506
}
428507

508+
/** Turns a live view on or off on the page. Angular profiling only turns on. */
509+
async setView(view: 'flash' | 'track' | 'angularProfiling', on: boolean) {
510+
const before = this.views();
511+
this.views.set({ ...before, [view]: on });
512+
const done = {
513+
flash: on ? 'Flash checks on.' : 'Flash checks off.',
514+
track: on
515+
? 'Performance track on. Record in the Chrome Performance panel to see it.'
516+
: 'Performance track off.',
517+
angularProfiling: 'Angular profiling on. It stays on until the page reloads.',
518+
}[view];
519+
const sent = await this.send(
520+
{ [view]: on },
521+
'',
522+
() => done,
523+
'request-change-detection-options',
524+
);
525+
if (!sent) this.views.set(before);
526+
}
527+
429528
private async send(
430-
request: { on?: boolean; clear?: boolean },
529+
request: { on?: boolean; clear?: boolean; flash?: boolean; track?: boolean },
431530
pending: string,
432531
done: () => string,
433-
) {
532+
rpc = 'request-change-detection-record',
533+
): Promise<boolean> {
434534
this.message.set(pending);
435535
try {
436536
if (!this.rpc()) throw new Error('no client');
437-
await call(this.rpc(), 'request-change-detection-record', {
537+
await call(this.rpc(), rpc, {
438538
pageId: this.pageId(),
439539
...request,
440540
});
441541
this.message.set('');
442542
this.announce(done());
543+
return true;
443544
} catch {
444545
const failed = 'Could not reach the page.';
445546
this.message.set(failed);
446547
this.announce(failed);
548+
return false;
447549
}
448550
}
449551

‎apps/docs/src/content/agents/tools.md‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,11 @@ Every tool has one of three safety tiers. The server sends the tier to your clie
5959
The twelve <code>action</code> and <code>destructive</code> tools act on the app. Your client can ask you before it runs them.
6060
</ngmd-alert>
6161

62-
When the hub is mounted, each finished call of an `action` or `destructive` tool posts an `Agent ran <tool>` message to the hub, so you can see what the agent changed. `change-detection` posts only when it starts, stops or clears a recording. See [Messages and badges](../getting-started/popup-and-hub.md#messages-and-badges).
62+
When the hub is mounted, each finished call of an `action` or `destructive` tool posts an `Agent ran <tool>` message to the hub, so you can see what the agent changed. `change-detection` posts only when it starts, stops or clears a recording, or turns a live view on or off. See [Messages and badges](../getting-started/popup-and-hub.md#messages-and-badges).
6363

6464
### Turn tools off
6565

66-
The server decides which tools exist. Set `agent.readOnly` to keep only the `read` tools. `change-detection` stays as a `read` tool without its `record` argument, so it reads a recording the panel started. Set `agent.tools.<inspector>` to `false` to hide one inspector's tools and resources, and keep its tab. Turning an inspector off with `inspectors`, or blocking an action with `actions`, drops the matching tools too. `actions.router` is the exception: `navigate` stays for `instrument` and `resolve-lazy`, and refuses `navigate`, `abort`, `replay` and `probe`. See [Inspectors and agent tools](../getting-started/configuration.md#inspectors-and-agent-tools).
66+
The server decides which tools exist. Set `agent.readOnly` to keep only the `read` tools. `change-detection` stays as a `read` tool without its `record`, `flash`, `track` and `angularProfiling` arguments, so it reads a recording the panel started. Set `agent.tools.<inspector>` to `false` to hide one inspector's tools and resources, and keep its tab. Turning an inspector off with `inspectors`, or blocking an action with `actions`, drops the matching tools too. `actions.router` is the exception: `navigate` stays for `instrument` and `resolve-lazy`, and refuses `navigate`, `abort`, `replay` and `probe`. See [Inspectors and agent tools](../getting-started/configuration.md#inspectors-and-agent-tools).
6767

6868
## Source scan
6969

@@ -160,14 +160,19 @@ Each page lists at most 50 blocks, with a note on how many more are not shown, a
160160

161161
Change detection cycles recorded with Angular's profiler: the slowest components by self time, the most often checked components, lifecycle hook time per class and hook, output listener time, `afterRender` callback time, and the latest cycles with their duration, component checks, sync passes, hook and afterRender time and the output that ran before each one. Reads: page. Needs Angular 20 or later.
162162

163-
| Argument | Required | Value |
164-
| -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
165-
| `record` | no | `start` starts a fresh recording on a page that is not recording and leaves a running one as it is, `stop` stops it and keeps the cycles, `clear` empties it. Leave out to read. |
166-
| `limit` | no | Rows per list. Default 10, at most 50. |
167-
| `export` | no | `json` returns the recording as the file the panel's **Import** reads. `trace` returns it in the Trace Event Format for Perfetto. Can't be combined with `record`. |
163+
| Argument | Required | Value |
164+
| ------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
165+
| `record` | no | `start` starts a fresh recording on a page that is not recording and leaves a running one as it is, `stop` stops it and keeps the cycles, `clear` empties it. Leave out to read. |
166+
| `limit` | no | Rows per list. Default 10, at most 50. |
167+
| `export` | no | `json` returns the recording as the file the panel's **Import** reads. `trace` returns it in the Trace Event Format for Perfetto. Can't be combined with `record`. |
168+
| `flash` | no | `true` turns the flash overlay on: a fading box with the class name and check count over each component Angular checks. `false` turns it off. |
169+
| `track` | no | `true` writes each component check, lifecycle hook and output listener to a **Pangular** track in the Chrome Performance panel. `false` turns it off. |
170+
| `angularProfiling` | no | `true` calls `ng.enableProfiling()` for Angular's own Performance panel track. It stays on until the page reloads, so `false` is refused. |
168171

169172
Without `page`, it reads the page that is recording, and `record` goes to every connected page. A `page` that names no connected tab gets the list of connected tabs. A connected tab that has not recorded yet gets a hint to start a recording there. The answer starts with the change detection mode of the page it reports on: zoneless, zone.js, or zoneless with zone.js still loaded. Recording is off until the panel or this tool starts it. Call it with `record: "start"`, use the app, then call it again without `record`. With `agent.readOnly`, the tool has no `record` argument and is marked read-only, so start the recording from the panel. When older cycles were dropped at [`limits.cdCycles`](../getting-started/configuration.md#limits), the answer says how many. A recording lists at most 100 component classes and counts checks for up to 2000 instances. When it checked more, the answer says how many were left out.
170173

174+
`flash`, `track` and `angularProfiling` go to the page in `page`, or to every connected page, and can be combined with `record`, but not with `export`. They are the **Live views** toggles of the panel; see [Watch checks live](../inspectors/components.md#watch-checks-live). Once a page reports them, the answer ends with a line that says which views are on. With `agent.readOnly`, the tool has none of these three arguments.
175+
171176
`export` only reads, so it stays available with `agent.readOnly`. The file is masked like other answers (JWTs, bearer tokens and secret names), has no instance check counts, and comes in a `json` code block to save as a `.json` file. An export over 100,000 characters is not returned, because cut JSON doesn't parse. The answer gives its size and the counts of cycles, timed spans, component classes, hook rows and output rows instead. Export it from the panel then, or clear the recording and record a shorter session. See [Export and import a recording](../inspectors/components.md#export-and-import-a-recording).
172177

173178
### Edit a component <ngmd-badge variant="alpha">Action</ngmd-badge>

0 commit comments

Comments
 (0)