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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions app/src/__tests__/signal-inspector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,136 @@ describe('SignalInspector', () => {
expect(fixture.componentInstance.graph()?.pageId).toBe('B');
});

describe('page picker', () => {
const owned = (pageId: string, id: string, label: string) => ({
...graph,
pageId,
nodes: [{ id: '1', kind: 'signal', label, epoch: 1, value: 'x' }],
component: { ...graph.component, id },
});
const a = owned('A', 'cA', 'fromA');
const b = owned('B', 'cB', 'fromB');
const trees = {
pages: {
A: {
title: 'Tab A',
url: '/a',
reportedAt: 1,
roots: [{ id: 'cA', name: 'AppA', tag: 'app-a', children: [] }],
},
B: {
title: 'Tab B',
url: '/b',
reportedAt: 2,
roots: [{ id: 'cB', name: 'AppB', tag: 'app-b', children: [] }],
},
},
};

async function renderPages(first: unknown) {
const listeners = new Set<(value: unknown) => void>();
const calls: { name: string; args: unknown }[] = [];
const rpc = {
call: (name: string, args: unknown) => {
calls.push({ name, args });
return Promise.resolve([]);
},
callEvent: () => Promise.resolve(),
sharedState: (name: string) =>
Promise.resolve({
value: () => (name === 'signal-graph' ? first : trees),
on: (_event: string, listener: (value: unknown) => void) => {
if (name === 'signal-graph') listeners.add(listener);
return () => listeners.delete(listener);
},
}),
};
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();
}
const push = async (value: unknown) => {
for (const listener of listeners) listener(value);
await fixture.whenStable();
};
return { fixture, calls, push };
}

function trigger(fixture: ComponentFixture<unknown>, labelId: string) {
return (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
`[aria-labelledby="${labelId}"][role="combobox"]`,
);
}

async function choose(fixture: ComponentFixture<unknown>, labelId: string, label: string) {
trigger(fixture, labelId)!.click();
await fixture.whenStable();
const option = Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll<HTMLElement>('[role="option"]'),
).find((el) => el.textContent?.includes(label));
expect(option).toBeDefined();
option!.click();
await fixture.whenStable();
}

function optionLabels(fixture: ComponentFixture<SignalInspector>) {
return fixture.componentInstance.componentOptions().map((o) => o.label);
}

it('shows no page picker when one page reports', async () => {
const { fixture } = await renderPages({ graph: a, pages: { A: a } });
expect(trigger(fixture, 'signals-page-label')).toBeNull();
expect(fixture.componentInstance.graph()?.pageId).toBe('A');
});

it('switches the graph and the Graph of options to the chosen page', async () => {
const { fixture } = await renderPages({ graph: b, pages: { A: a, B: b } });
const host = fixture.nativeElement as HTMLElement;
expect(trigger(fixture, 'signals-page-label')?.textContent).toContain('Tab B');
expect(host.textContent).toContain('fromB');
expect(optionLabels(fixture)).toContain('AppB');

await choose(fixture, 'signals-page-label', 'Tab A');
expect(fixture.componentInstance.graph()?.pageId).toBe('A');
expect(host.textContent).toContain('fromA');
expect(host.textContent).not.toContain('fromB');
expect(optionLabels(fixture)).toContain('AppA');
expect(optionLabels(fixture)).not.toContain('AppB');
expect(notices(fixture)).toContain('Showing the signal graph of Tab A.');
});

it('sends select-signal-target with the chosen page id and keeps a pick per page', async () => {
const { fixture, calls } = await renderPages({ graph: b, pages: { A: a, B: b } });
await choose(fixture, 'signals-page-label', 'Tab A');
await choose(fixture, 'signals-component-label', 'AppA');
expect(calls.filter((c) => c.name === 'select-signal-target')).toEqual([
{ name: 'select-signal-target', args: { pageId: 'A', id: 'cA' } },
]);

await choose(fixture, 'signals-page-label', 'Tab B');
expect(fixture.componentInstance.pickerValue()).toBe('follow');
await choose(fixture, 'signals-page-label', 'Tab A');
expect(fixture.componentInstance.pickerValue()).toBe('cA');
});

it('keeps the chosen page when another page reports, and falls back when it closes', async () => {
const { fixture, push } = await renderPages({ graph: b, pages: { A: a, B: b } });
await choose(fixture, 'signals-page-label', 'Tab A');
await push({ graph: b, pages: { A: a, B: b } });
expect(fixture.componentInstance.graph()?.pageId).toBe('A');

await push({ graph: b, pages: { B: b } });
expect(fixture.componentInstance.graph()?.pageId).toBe('B');
expect(trigger(fixture, 'signals-page-label')).toBeNull();
expect(notices(fixture)).toContain('Tab A closed, so this shows another page.');
});
});

it('offers Clear filters when a filter hides the only resource of a resource-only graph', async () => {
const fixture = await render({
...graph,
Expand Down
149 changes: 120 additions & 29 deletions app/src/pages/signal-inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import {
effect,
inject,
input,
linkedSignal,
signal,
} from '@angular/core';
import { DatePipe, JsonPipe, NgTemplateOutlet } from '@angular/common';
import type { DevframeRpcClient } from 'devframe/client';
import { pickPage, type ReportedPage } from '../live-pages';
import { hostPageId } from '../page-id';
import { Select, type SelectOption } from '../ui/select';

Expand Down Expand Up @@ -147,6 +149,17 @@ const KIND_COLORS: Record<string, string> = {
[value]="filter()"
(input)="filter.set($any($event.target).value)"
/>
@if (pageOptions().length > 1) {
<div class="picker page-picker">
<span class="label-key" id="signals-page-label">Page</span>
<app-select
labelledBy="signals-page-label"
[options]="pageOptions()"
[value]="shownPageId()"
(valueChange)="selectPage($event)"
/>
</div>
}
@if (componentOptions().length) {
<div class="picker">
<span class="label-key" id="signals-component-label">Graph of</span>
Expand All @@ -159,6 +172,7 @@ const KIND_COLORS: Record<string, string> = {
</div>
}
</div>
<p class="sr-only" role="status">{{ announcement() }}</p>

@if (graph()?.component; as comp) {
<p class="showing">
Expand Down Expand Up @@ -882,6 +896,9 @@ const KIND_COLORS: Record<string, string> = {
flex: 1 1 auto;
min-width: 0;
}
.page-picker {
flex: 0 1 240px;
}
.label-key {
flex: none;
color: var(--text-2);
Expand Down Expand Up @@ -1208,13 +1225,73 @@ export class SignalInspector {
private readonly injector = inject(Injector);
private readonly pageId = hostPageId();
private readonly cleanups: (() => void)[] = [];
private readonly treePages = signal<Record<string, { roots?: LiveNode[]; reportedAt?: number }>>(
{},
);
readonly picked = signal<string | null>(null);
private readonly treePages = signal<
Record<string, { roots?: LiveNode[]; reportedAt?: number; title?: string; url?: string }>
>({});
private readonly graphState = signal<{
graph: SignalGraph | null;
pages: Record<string, SignalGraph>;
}>({ graph: null, pages: {} });
readonly chosenPageId = signal<string | null>(null);
readonly announcement = signal('');

// Older servers send only the newest graph, without the per-page map.
private readonly graphPages = computed<Record<string, SignalGraph>>(() => {
const { graph, pages } = this.graphState();
if (Object.keys(pages).length) return pages;
return graph ? { [graph.pageId ?? '']: graph } : {};
});

// The newest graph comes as `graph`; a page that reports later never takes over.
readonly shownPageId = linkedSignal<
{ pages: Record<string, SignalGraph>; newest: string | null; chosen: string | null },
string | null
>({
source: () => ({
pages: this.graphPages(),
newest: this.graphState().graph?.pageId ?? null,
chosen: this.chosenPageId(),
}),
computation: ({ pages, newest, chosen }, previous) => {
const reported: Record<string, ReportedPage> = {};
for (const pageId of Object.keys(pages)) {
reported[pageId] = { pageId, reportedAt: pageId === newest ? 1 : 0 };
}
return pickPage(reported, { chosen, host: this.pageId, previous: previous?.value ?? null });
},
});

private readonly reported = computed<SignalGraph | null>(() => {
const pageId = this.shownPageId();
return pageId === null ? null : (this.graphPages()[pageId] ?? null);
});
readonly unsupported = computed(() => !!this.reported()?.unsupported);
readonly graph = computed<SignalGraph | null>(() => {
const reported = this.reported();
return reported?.unsupported ? null : reported;
});
private readonly graphOwner = computed(() => {
const g = this.graph();
return g ? `${g.pageId ?? ''}|${g.component?.id ?? g.injector?.id ?? ''}` : null;
});

// Component and injector ids belong to one page, so each page keeps its own pick.
private readonly pickedByPage = signal<Record<string, string>>({});
readonly picked = computed(() => this.pickedByPage()[this.targetPageId() ?? ''] ?? null);

readonly pageOptions = computed<SelectOption[]>(() => {
if (this.pageId) return [];
const tree = this.treePages();
return Object.keys(this.graphPages()).map((pageId) => {
const page = tree[pageId];
return {
value: pageId,
label: page?.title || page?.url || `Page ${pageId}`,
hint: page?.url,
};
});
});

graph = signal<SignalGraph | null>(null);
readonly unsupported = signal(false);
readonly showInternals = signal(false);
sourceSignals = signal<SourceSignal[]>([]);
sourceLoaded = signal(false);
Expand All @@ -1238,7 +1315,10 @@ export class SignalInspector {
return [...counts].map(([kind, count]) => ({ kind, count }));
});
kindTotal = computed(() => this.kindCounts().reduce((sum, g) => sum + g.count, 0));
selectedId = signal<string | null>(null);
selectedId = linkedSignal<string | null, string | null>({
source: this.graphOwner,
computation: () => null,
});
selectedNode = computed(
() => this.graph()?.nodes.find((n) => n.id === this.selectedId()) ?? null,
);
Expand Down Expand Up @@ -1290,7 +1370,12 @@ export class SignalInspector {
});

private readonly targetPageId = computed(
() => this.pageId ?? this.graph()?.pageId ?? this.latestTreePage()?.pageId ?? null,
() =>
this.pageId ??
(this.shownPageId() || null) ??
this.graph()?.pageId ??
this.latestTreePage()?.pageId ??
null,
);

private readonly latestTreePage = computed(() => {
Expand Down Expand Up @@ -1362,18 +1447,18 @@ export class SignalInspector {
const state = await my.rpc.sharedState('signal-graph');
if (this.destroyRef.destroyed) return;
const apply = (value: unknown) => {
const next = value as { graph?: SignalGraph | null; pages?: Record<string, SignalGraph> };
const reported = this.reportedGraph(next);
this.unsupported.set(!!reported?.unsupported);
const graph = reported?.unsupported ? null : reported;
const owner = (g: SignalGraph | null) => g?.component?.id ?? g?.injector?.id;
if (owner(graph) !== owner(this.graph())) this.selectedId.set(null);
this.graph.set(graph);
const next = value as {
graph?: SignalGraph | null;
pages?: Record<string, SignalGraph>;
} | null;
const pages = next?.pages && typeof next.pages === 'object' ? next.pages : {};
this.notePageGone(pages);
this.graphState.set({ graph: next?.graph ?? null, pages });
};
apply(state.value());
this.cleanups.push(state.on('updated', apply));
} catch {
this.graph.set(null);
this.graphState.set({ graph: null, pages: {} });
}
try {
const tree = await my.rpc.sharedState('component-tree');
Expand All @@ -1389,26 +1474,32 @@ export class SignalInspector {
}
}

private lockedPage: string | null = null;
private notePageGone(pages: Record<string, SignalGraph>) {
const chosen = this.chosenPageId();
if (!chosen || pages[chosen]) return;
const label = this.pageOptions().find((o) => o.value === chosen)?.label ?? 'The chosen page';
this.chosenPageId.set(null);
this.announcement.set(`${label} closed, so this shows another page.`);
}

private reportedGraph(next: {
graph?: SignalGraph | null;
pages?: Record<string, SignalGraph>;
}): SignalGraph | null {
if (this.pageId) return next?.pages?.[this.pageId] ?? null;
const locked = this.lockedPage ? next?.pages?.[this.lockedPage] : undefined;
if (locked) return locked;
const latest = next?.graph ?? null;
this.lockedPage = latest?.pageId ?? null;
return latest;
selectPage(pageId: string | null) {
if (!pageId || pageId === this.shownPageId()) return;
this.chosenPageId.set(pageId);
const label = this.pageOptions().find((o) => o.value === pageId)?.label ?? pageId;
this.announcement.set(`Showing the signal graph of ${label}.`);
}

pickComponent(value: string | null) {
const picked = value && value !== FOLLOW ? value : null;
this.picked.set(picked);
const pageId = this.targetPageId() ?? undefined;
this.pickedByPage.update((all) => {
const next = { ...all };
if (picked) next[pageId ?? ''] = picked;
else delete next[pageId ?? ''];
return next;
});
const client = this.rpc();
if (!client) return;
const pageId = this.targetPageId() ?? undefined;
const target = picked?.startsWith(ENV)
? { pageId, env: picked.slice(ENV.length) }
: { pageId, id: picked };
Expand Down
6 changes: 6 additions & 0 deletions apps/docs/src/content/inspectors/signals.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ The Signals tab shows the reactive graph of one component, or of the root or a r

## What it shows

### Page picker

When more than one open page reports a signal graph, 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 first page that reports, and stays on the page you choose when another page reports later. The **Graph of** options follow the chosen page, and each page keeps its own **Graph of** choice.

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.

### Graph picker

The **Graph of** picker at the top selects whose graph you see. It appears when a live component tree or an injector exists.
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading