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
90 changes: 90 additions & 0 deletions app/src/__tests__/signal-inspector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,96 @@ describe('SignalInspector', () => {
expect(trigger(fixture, 'signals-page-label')).toBeNull();
expect(notices(fixture)).toContain('Tab A closed, so this shows another page.');
});

it('forgets the pick of a page that closed', async () => {
const { fixture, push } = 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(fixture.componentInstance.pickerValue()).toBe('cA');

await push({ graph: b, pages: { B: b } });
await push({ graph: b, pages: { A: a, B: b } });
await choose(fixture, 'signals-page-label', 'Tab A');
expect(fixture.componentInstance.pickerValue()).toBe('follow');
});
});

describe('kind chips', () => {
const listeners = new Set<(value: unknown) => void>();
const owned = (id: string, kinds: string[]) => ({
...graph,
nodes: kinds.map((kind, i) => ({ id: String(i + 1), kind, label: `${kind}${i}`, epoch: 1 })),
component: { ...graph.component, id },
});

async function renderLive(first: unknown) {
listeners.clear();
const rpc = {
call: () => Promise.resolve([]),
callEvent: () => Promise.resolve(),
sharedState: (name: string) =>
Promise.resolve({
value: () => (name === 'signal-graph' ? { graph: first } : { pages: {} }),
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 (next: unknown) => {
for (const listener of listeners) listener({ graph: next });
await fixture.whenStable();
};
return { fixture, push };
}

function pressed(fixture: ComponentFixture<unknown>) {
return Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll('.kind-chip[aria-pressed="true"]'),
(el) => el.textContent?.replace(/\s+/g, ''),
);
}

it('resets a kind the newly picked component does not have', async () => {
const { fixture, push } = await renderLive(owned('c1', ['signal', 'linkedSignal']));
fixture.componentInstance.kind.set('linkedSignal');
await fixture.whenStable();

await push(owned('c2', ['signal', 'computed']));
expect(fixture.componentInstance.kind()).toBeNull();
expect(pressed(fixture)).toEqual(['All2']);
expect((fixture.nativeElement as HTMLElement).textContent).not.toContain('No signals match.');
});

it('keeps a kind the newly picked component also has', async () => {
const { fixture, push } = await renderLive(owned('c1', ['signal', 'computed']));
fixture.componentInstance.kind.set('computed');
await fixture.whenStable();

await push(owned('c2', ['computed', 'effect']));
expect(fixture.componentInstance.kind()).toBe('computed');
expect(pressed(fixture)).toEqual(['computed1']);
});

it('shows an active kind with no matches as a pressed chip with count 0', async () => {
const { fixture, push } = await renderLive(owned('c1', ['signal', 'computed']));
fixture.componentInstance.kind.set('computed');
await fixture.whenStable();

await push(owned('c1', ['signal']));
expect(fixture.componentInstance.kind()).toBe('computed');
expect(pressed(fixture)).toEqual(['computed0']);
expect((fixture.nativeElement as HTMLElement).textContent).toContain('No signals match.');
});
});

it('offers Clear filters when a filter hides the only resource of a resource-only graph', async () => {
Expand Down
49 changes: 38 additions & 11 deletions app/src/pages/signal-inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
input,
linkedSignal,
signal,
untracked,
viewChild,
} from '@angular/core';
import { DatePipe, JsonPipe, NgTemplateOutlet } from '@angular/common';
Expand Down Expand Up @@ -84,6 +85,15 @@ interface LiveNode {
}

const FOLLOW = 'follow';

// Older servers send only the newest graph, without the per-page map.
function graphPagesOf(state: {
graph?: SignalGraph | null;
pages: Record<string, SignalGraph>;
}): Record<string, SignalGraph> {
if (Object.keys(state.pages).length) return state.pages;
return state.graph ? { [state.graph.pageId ?? '']: state.graph } : {};
}
const ENV = 'env:';
const MAX_HISTORY = 50;

Expand Down Expand Up @@ -225,7 +235,7 @@ const KIND_COLORS: Record<string, string> = {
left out.
</p>
}
@if (kindCounts().length) {
@if (kindChips().length) {
<div class="kinds" role="group" aria-label="Filter by kind">
<button
type="button"
Expand All @@ -236,7 +246,7 @@ const KIND_COLORS: Record<string, string> = {
>
All <span class="count">{{ kindTotal() }}</span>
</button>
@for (group of kindCounts(); track group.kind) {
@for (group of kindChips(); track group.kind) {
<button
type="button"
class="kind-chip"
Expand Down Expand Up @@ -1239,12 +1249,7 @@ export class SignalInspector {
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 } : {};
});
private readonly graphPages = computed(() => graphPagesOf(this.graphState()));

// The newest graph comes as `graph`; a page that reports later never takes over.
readonly shownPageId = linkedSignal<
Expand Down Expand Up @@ -1300,7 +1305,6 @@ export class SignalInspector {
sourceSignals = signal<SourceSignal[]>([]);
sourceLoaded = signal(false);
filter = signal('');
kind = signal<string | null>(null);
readonly resources = computed(() => this.graph()?.resources ?? []);
private readonly internalIds = computed(
() => new Set(this.resources().flatMap((r) => r.nodeIds)),
Expand All @@ -1318,6 +1322,20 @@ export class SignalInspector {
for (const k of kinds) counts.set(k, (counts.get(k) ?? 0) + 1);
return [...counts].map(([kind, count]) => ({ kind, count }));
});
// A kind picked on another graph stays only when this graph has it too.
kind = linkedSignal<string | null, string | null>({
source: this.graphOwner,
computation: (_owner, previous) => {
const kind = previous?.value ?? null;
return kind && untracked(this.kindCounts).some((g) => g.kind === kind) ? kind : null;
},
});
// An active kind with no matches still gets a pressed chip, so the filter stays visible.
kindChips = computed(() => {
const counts = this.kindCounts();
const kind = this.kind();
return kind && !counts.some((g) => g.kind === kind) ? [...counts, { kind, count: 0 }] : counts;
});
kindTotal = computed(() => this.kindCounts().reduce((sum, g) => sum + g.count, 0));
selectedId = linkedSignal<string | null, string | null>({
source: this.graphOwner,
Expand Down Expand Up @@ -1456,8 +1474,9 @@ export class SignalInspector {
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 });
const state = { graph: next?.graph ?? null, pages };
this.notePageGone(graphPagesOf(state));
this.graphState.set(state);
};
apply(state.value());
this.cleanups.push(state.on('updated', apply));
Expand All @@ -1479,6 +1498,14 @@ export class SignalInspector {
}

private notePageGone(pages: Record<string, SignalGraph>) {
const gone = Object.keys(this.graphPages()).filter((pageId) => !pages[pageId]);
if (gone.length) {
this.pickedByPage.update((all) => {
const next = { ...all };
for (const pageId of gone) delete next[pageId];
return next;
});
}
const chosen = this.chosenPageId();
if (!chosen || pages[chosen]) return;
const label = this.pageOptions().find((o) => o.value === chosen)?.label ?? 'The chosen page';
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/content/inspectors/signals.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ A line under the picker names the component, its host path, and why it was chose

### Node cards

Filter by name, or by kind with the chips. Each card shows:
Filter by name, or by kind with the chips. If you pick another component or injector and its graph has no node of the chosen kind, the filter goes back to **All**. If the chosen kind runs out of nodes in the same graph, its chip stays pressed with a count of 0. Each card shows:

- Its kind and label. Kinds come from Angular, such as `signal`, `computed`, `linkedSignal`, `effect` and `template`. Nodes without a name show **(unnamed)**.
- The current value.
Expand Down

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

Loading
Loading