diff --git a/app/src/__tests__/component-tree-hydration.test.ts b/app/src/__tests__/component-tree-hydration.test.ts new file mode 100644 index 00000000..5c6a5626 --- /dev/null +++ b/app/src/__tests__/component-tree-hydration.test.ts @@ -0,0 +1,280 @@ +import { TestBed } from '@angular/core/testing'; +import type { DevframeRpcClient } from 'devframe/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ComponentTree } from '../pages/component-tree'; +import { + countText, + directiveHostLabel, + filterAnnouncement, + filterTree, + withoutDirectiveHosts, + type TreeNode, +} from '../pages/component-tree-state'; + +function client(pages: Record) { + 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 === 'component-tree' ? { pages } : null), + on: () => () => {}, + }), + }; + return { + calls, + client: { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient, + }; +} + +const roots: TreeNode[] = [ + { + id: 'c1', + name: 'App', + tag: 'app-root', + hydration: 'hydrated', + children: [ + { + id: 'c2', + name: 'RouterLink', + tag: 'a', + kind: 'directive', + directives: ['RouterLink'], + attributes: ['routerLink'], + children: [ + { id: 'c3', name: 'Badge', tag: 'app-badge', hydration: 'hydrated', children: [] }, + ], + }, + { + id: 'c4', + name: 'TripMap', + tag: 'app-map', + hydration: 'skipped', + children: [ + { + id: 'c5', + name: 'MatTooltip', + tag: 'button', + kind: 'directive', + directives: ['MatTooltip'], + attributes: ['matTooltip'], + children: [], + }, + ], + }, + { id: 'c6', name: 'Price', tag: 'app-price', hydration: 'mismatched', children: [] }, + ], + }, +]; + +const hydration = { + hydrated: true, + counts: { hydrated: 2, skipped: 1, mismatched: 1, client: 0, dehydratedBlocks: 1 }, + mismatches: [{ id: 'c6', name: 'Price', tag: 'app-price', expected: '
', actual: '' }], +}; + +const page = (extra: Record = {}) => ({ + pageId: 'p1', + roots, + count: 4, + directiveHostCount: 2, + detail: null, + reportedAt: 1000, + hydration, + ...extra, +}); + +async function render(value: unknown) { + const fixture = TestBed.createComponent(ComponentTree); + const rpc = client({ p1: value }); + fixture.componentRef.setInput('rpc', rpc.client); + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + } + const el = fixture.nativeElement as HTMLElement; + const settle = async () => { + fixture.detectChanges(); + await fixture.whenStable(); + }; + return { fixture, el, calls: rpc.calls, settle }; +} + +const rowIds = (el: HTMLElement) => + Array.from(el.querySelectorAll('.row')).map((row) => row.dataset['id']); + +describe('tree search and directive hosts (state)', () => { + it('filters with tag, directive and text tokens, keeping ancestors', () => { + const { nodes, matches } = filterTree(roots, '
@if (pageOptions().length > 1) { Page @@ -194,6 +234,20 @@ function bare(name: string): string { /> } {{ countLabel() }} + @if (page()!.directiveHostCount) { + + } } @@ -269,6 +326,7 @@ function bare(name: string): string { [attr.tabindex]="rovingId() === row.node.id ? 0 : -1" [attr.data-id]="row.node.id" [class.selected]="selectedId() === row.node.id" + [class.dir-host]="row.node.kind === 'directive'" [style.--depth]="row.depth" (click)="select(row.node.id)" (focus)="focusId.set(row.node.id); highlight(row.node.id)" @@ -288,8 +346,21 @@ function bare(name: string): string { } @else { } - {{ row.node.name }} - <{{ row.node.tag }}> + @if (row.node.kind === 'directive') { + <{{ row.node.tag }}> + {{ + hostLabel(row.node) + }} + directive host + } @else { + {{ row.node.name }} + <{{ row.node.tag }}> + } + @if (row.node.hydration; as status) { + Hydration: {{ hydrationText(status) }} + } @for (hit of routedById().get(row.node.id) ?? []; track hit.outlet + hit.route) { {{ hit.route || '/' }} } @@ -301,7 +372,7 @@ function bare(name: string): string { > } - @if (row.node.directives?.length) { + @if (row.node.kind !== 'directive' && row.node.directives?.length) { +{{ row.node.directives!.length }} @@ -314,7 +385,38 @@ function bare(name: string): string { - @if (selectedNode(); as sel) { + @if (selectedHost(); as host) { +
+
+ directive host +

<{{ host.tag }}>

+ {{ hostLabel(host) }} +
+
+

+ Directives {{ host.directives?.length ?? 0 }} +

+
    + @for (name of host.directives ?? []; track name + $index) { +
  • {{ name }}
  • + } +
+
+
+

+ This element hosts directives but no component. Their inputs are bound in the + template of the component that renders it. +

+ @if (ownerOf(host.id); as owner) { +
+ +
+ } +
+
+ } @else if (selectedNode(); as sel) {
component @@ -376,127 +478,185 @@ function bare(name: string): string { } -
-

- Inputs {{ d.inputs.length }} -

- @if (d.inputs.length) { -
    - @for (p of d.inputs; track p.name) { -
  • - - {{ p.name }} - @if (p.prop !== p.name) { - ({{ p.prop }}) - } - - -
  • - } -
- } @else { -

No inputs.

+
+ + @if (propQuery()) { + {{ shownValues()?.count ?? 0 }} of {{ valueTotal() }} } +

{{ propFilterStatus() }}

-
-

- Outputs {{ d.outputs.length }} -

- @if (d.outputs.length) { -
    - @for (p of d.outputs; track p.name) { -
  • - {{ p.name }} - {{ p.listened ? 'listened' : 'no listener' }} -
  • + @if (shownValues(); as v) { + @if (propQuery() && !v.count) { +
    +

    + No inputs, outputs or properties match “{{ propFilter().trim() }}” +

    +

    + The filter searches names, and the nested keys of values you opened. +

    + +
    + } + + @if (!propQuery() || v.inputs.length) { +
    +

    + Inputs {{ v.inputs.length }} +

    + @if (v.inputs.length) { +
      + @for (p of v.inputs; track p.name) { +
    • + + {{ p.name }} + @if (p.prop !== p.name) { + ({{ p.prop }}) + } + + +
    • + } +
    + } @else { +

    No inputs.

    } -
- } @else { -

No outputs.

+
} -
-
-

- Properties {{ d.properties?.length ?? 0 }} -

- @if (d.properties?.length) { -
    - @for (p of d.properties; track p.name) { -
  • - - {{ p.name }} - @if (p.kind) { - {{ p.kind }} - } - - -
  • + @if (!propQuery() || v.outputs.length) { +
    +

    + Outputs {{ v.outputs.length }} +

    + @if (v.outputs.length) { +
      + @for (p of v.outputs; track p.name) { +
    • + {{ p.name }} + {{ + p.listened ? 'listened' : 'no listener' + }} +
    • + } +
    + } @else { +

    No outputs.

    } -
- } @else { -

No other properties.

+
} - - @if (d.listeners.length) { -
-

- DOM listeners {{ d.listeners.length }} -

-
    - @for (name of d.listeners; track name) { -
  • {{ name }}
  • + @if (!propQuery() || v.properties.length) { +
    +

    + Properties {{ v.properties.length }} +

    + @if (v.properties.length) { +
      + @for (p of v.properties; track p.name) { +
    • + + {{ p.name }} + @if (p.kind) { + {{ p.kind }} + } + + +
    • + } +
    + } @else { +

    No other properties.

    } -
-
- } + + } - @for (dir of d.directives; track dir.name + $index) { -
-

- {{ dir.name }} - directive on host -

- @if (dir.inputs.length) { -
    - @for (p of dir.inputs; track p.name) { -
  • - {{ p.name }} -
    {{ p.value | json }}
    -
  • - } -
- } - @if (dir.outputs.length) { + @if (!propQuery() && d.listeners.length) { +
+

+ DOM listeners {{ d.listeners.length }} +

    - @for (p of dir.outputs; track p.name) { -
  • - {{ p.name }} - {{ - p.listened ? 'listened' : 'no listener' - }} -
  • + @for (name of d.listeners; track name) { +
  • {{ name }}
  • }
- } - @if (!dir.inputs.length && !dir.outputs.length) { -

No inputs or outputs.

- } -
+
+ } + + @for (dir of v.directives; track dir.name + dir.index) { +
+

+ {{ dir.name }} + directive on host +

+ @if (dir.inputs.length) { +
    + @for (p of dir.inputs; track p.name) { +
  • + {{ p.name }} + +
  • + } +
+ } + @if (dir.outputs.length) { +
    + @for (p of dir.outputs; track p.name) { +
  • + {{ p.name }} + {{ + p.listened ? 'listened' : 'no listener' + }} +
  • + } +
+ } + @if (!dir.inputs.length && !dir.outputs.length) { +

No inputs or outputs.

+ } +
+ } }
@@ -590,6 +750,14 @@ function bare(name: string): string {
} + @if (!native()) { + + } '', }); readonly collapsed = signal>(new Set()); + /** Elements that host directives but no component show as rows; this viewer can hide them. */ + readonly showDirectiveHosts = signal(storedDirectiveHosts()); readonly openKey = signal(null); private readonly routerPages = signal<{ pageId?: string; outlets?: OutletInfo[] }[]>([]); private readonly pageId = hostPageId(); @@ -1514,6 +1721,14 @@ export class ComponentTree { }); readonly cdHosts = computed(() => this.cdPage()?.hosts ?? {}); + /** The page's tree, without directive hosts when this viewer hid them. */ + private readonly visibleRoots = computed(() => { + const roots = this.page()?.roots ?? []; + return this.showDirectiveHosts() || !this.page()?.directiveHostCount + ? roots + : withoutDirectiveHosts(roots); + }); + private readonly index = computed(() => { const map = new Map(); const parents = new Map(); @@ -1524,13 +1739,18 @@ export class ComponentTree { walk(node.children, node.id); } }; - walk(this.page()?.roots ?? [], null); + walk(this.visibleRoots(), null); return { map, parents }; }); private readonly query = computed(() => this.filter().trim().toLowerCase()); - private readonly filtered = computed(() => filterTree(this.page()?.roots ?? [], this.query())); + private readonly filtered = computed(() => filterTree(this.visibleRoots(), this.query())); + + /** Directive host rows in the tree as shown. */ + private readonly shownDirectiveHosts = computed(() => + this.showDirectiveHosts() ? (this.page()?.directiveHostCount ?? 0) : 0, + ); readonly deferBlocks = computed(() => this.page()?.deferBlocks ?? []); /** The block count, with a `+` when the page has more blocks than it reported. */ @@ -1544,6 +1764,7 @@ export class ComponentTree { this.filtered().matches, !!this.query(), !!this.page()?.truncated, + this.shownDirectiveHosts(), ), ); @@ -1569,11 +1790,83 @@ export class ComponentTree { return id ? (this.index().map.get(id) ?? null) : null; }); + /** The selected row when it is a directive host, which has no component detail. */ + readonly selectedHost = computed(() => { + const node = this.selectedNode(); + return node?.kind === 'directive' ? node : null; + }); + readonly detail = computed(() => { const detail = this.page()?.detail; return detail && detail.id === this.selectedId() ? detail : null; }); + /** Opened value levels of the selected component. */ + readonly values = new ValueStore(); + /** The detail's filter over input, output and property names and opened nested keys. */ + readonly propFilter = signal(''); + readonly propQuery = computed(() => this.propFilter().trim().toLowerCase()); + private readonly propFilterInput = viewChild>('propFilterInput'); + + /** Inputs, outputs, properties and directive inputs the filter keeps, and how many. */ + readonly shownValues = computed(() => { + const d = this.detail(); + if (!d) return null; + const query = this.propQuery(); + const named = (p: Prop) => + !query || p.name.toLowerCase().includes(query) || p.prop.toLowerCase().includes(query); + const kept = (p: Prop, directive?: number) => + named(p) || + nestedMatches([p.prop], p.value, (path) => this.values.level(path, directive), query, 1) + .length > 0; + const inputs = d.inputs.filter((p) => kept(p)); + const outputs = d.outputs.filter(named); + const properties = (d.properties ?? []).filter((p) => kept(p)); + const directives = d.directives + .map((dir, index) => ({ + ...dir, + index, + inputs: dir.inputs.filter((p) => kept(p, index)), + outputs: dir.outputs.filter(named), + })) + .filter((dir) => !query || dir.inputs.length || dir.outputs.length); + const count = + inputs.length + + outputs.length + + properties.length + + directives.reduce((n, dir) => n + dir.inputs.length + dir.outputs.length, 0); + return { inputs, outputs, properties, directives, count }; + }); + + readonly valueTotal = computed(() => { + const d = this.detail(); + if (!d) return 0; + return ( + d.inputs.length + + d.outputs.length + + (d.properties?.length ?? 0) + + d.directives.reduce((n, dir) => n + dir.inputs.length + dir.outputs.length, 0) + ); + }); + + readonly propFilterStatus = computed(() => { + if (!this.propQuery()) return ''; + const count = this.shownValues()?.count ?? 0; + return count + ? `${count} of ${this.valueTotal()} values match.` + : 'No inputs, outputs or properties match.'; + }); + + /** Clears the detail filter and keeps focus in its field. */ + clearPropFilter(event?: Event) { + if (event && this.propFilter()) { + event.stopPropagation(); + event.preventDefault(); + } + this.propFilter.set(''); + this.propFilterInput()?.nativeElement.focus(); + } + readonly rovingId = computed(() => { const rows = this.rows(); const ids = new Set(rows.map((r) => r.node.id)); @@ -1632,6 +1925,15 @@ export class ComponentTree { }); constructor() { + effect(() => { + const detail = this.detail(); + const target = { + rpc: this.rpc(), + pageId: this.page()?.pageId ?? null, + componentId: detail?.id ?? null, + }; + untracked(() => this.values.follow(target, detail?.expanded)); + }); effect(() => { const shown = this.page()?.pageId ?? null; untracked(() => { @@ -1841,7 +2143,13 @@ export class ComponentTree { this.filter.set(value); const { matches } = this.filtered(); this.announcement.set( - filterAnnouncement(this.page()?.count ?? 0, matches, value, !!this.page()?.truncated), + filterAnnouncement( + this.page()?.count ?? 0, + matches, + value, + !!this.page()?.truncated, + this.shownDirectiveHosts(), + ), ); } @@ -1915,10 +2223,60 @@ export class ComponentTree { this.selectedId.set(next); this.destroyed.set(null); this.focusId.set(id); + // A directive host has no component detail for the page to send. + if (next && this.index().map.get(next)?.kind === 'directive') return; this.sendSelection(next); } - private reveal(id: string) { + hostLabel(node: LiveNode) { + return directiveHostLabel(node); + } + + hydrationText(status: HydrationStatus) { + return hydrationLabel(status); + } + + /** The nearest component above a row, such as the one whose template renders a directive host. */ + ownerOf(id: string): LiveNode | null { + const { map, parents } = this.index(); + for (let parent = parents.get(id); parent; parent = parents.get(parent)) { + const node = map.get(parent); + if (node && node.kind !== 'directive') return node; + } + return null; + } + + toggleDirectiveHosts() { + const next = !this.showDirectiveHosts(); + this.showDirectiveHosts.set(next); + try { + localStorage.setItem(DIRECTIVE_HOSTS_KEY, next ? 'shown' : 'hidden'); + } catch { + // the choice lasts for this view only + } + const selected = this.selectedId(); + if (!next && selected && !this.index().map.has(selected)) this.selectedId.set(null); + this.announce(next ? 'Directive hosts are shown.' : 'Directive hosts are hidden.'); + } + + setHydrationOverlay(on: boolean) { + const client = this.rpc(); + const pageId = this.page()?.pageId; + if (!client || !pageId) return; + void client + .scope('pangular') + .rpc.call('request-hydration-overlay', { pageId, on }) + .catch(() => {}); + this.announce(on ? 'Outlining hydration on the page.' : 'Hydration outline removed.'); + } + + /** Shows a node that the hydration summary names, when it is in the tree. */ + revealNode(id: string) { + if (this.index().map.has(id)) this.reveal(id); + } + + /** Opens the tree to a node and selects it; `focus` also moves keyboard focus to its row. */ + reveal(id: string, focus = false) { const { parents } = this.index(); this.collapsed.update((set) => { const next = new Set(set); @@ -1927,11 +2285,13 @@ export class ComponentTree { }); if (!this.rows().some((row) => row.node.id === id)) this.setFilter(''); if (this.selectedId() !== id) this.select(id); + if (focus) this.focusId.set(id); afterNextRender( - () => - document - .querySelector(`.row[data-id="${CSS.escape(id)}"]`) - ?.scrollIntoView({ block: 'nearest' }), + () => { + const row = document.querySelector(`.row[data-id="${CSS.escape(id)}"]`); + row?.scrollIntoView({ block: 'nearest' }); + if (focus) row?.focus(); + }, { injector: this.injector }, ); } diff --git a/app/src/pages/component-value-state.ts b/app/src/pages/component-value-state.ts new file mode 100644 index 00000000..ee75e8c0 --- /dev/null +++ b/app/src/pages/component-value-state.ts @@ -0,0 +1,241 @@ +/** + * Reading the value levels the page sends for the component detail. A level + * is `serialize()` output with `lazy` on: a nested container the page did not + * read is `{ $collapsed: type, size }`, a cut array ends with `{ $more: n }`, + * a cut object has a `$more` count, a Map is `{ $type: 'Map', size, entries }` + * and a Set `{ $type: 'Set', size, values }`. + */ + +export type ContainerKind = 'array' | 'object' | 'map' | 'set'; + +export interface ValueContainer { + kind: ContainerKind; + /** Class name, or Object, Array, Map, Set. */ + type: string; + /** Entries in the app, listed or not. */ + size: number; + /** The page did not read inside it yet. */ + collapsed: boolean; +} + +export interface ValueChild { + /** Path segment: key, array index, or entry index of a Map or Set. */ + key: string; + /** What the row shows: the key, the index, or a Map entry's key. */ + label: string; + value: unknown; +} + +/** Entries the panel asks for at a time when the user loads more. */ +export const LOAD_STEP = 100; +/** Most entries one level can list. */ +export const MAX_LIMIT = 1000; +/** Entries the page lists per level unless asked for more. */ +export const FIRST_LIMIT = 30; +/** How deep and wide the panel reads a value to edit it whole (`EDIT_LEVEL` on the page). */ +export const EDIT_READ = { depth: 5, limit: 100 } as const; + +const REDACTED = '[redacted]'; + +type Obj = Record; + +function isObj(value: unknown): value is Obj { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function isMore(value: unknown): value is { $more: number } { + return isObj(value) && typeof value['$more'] === 'number' && Object.keys(value).length === 1; +} + +function isCollapsed(value: unknown): value is { $collapsed: string; size: number } { + return ( + isObj(value) && + typeof value['$collapsed'] === 'string' && + typeof value['size'] === 'number' && + Object.keys(value).length === 2 + ); +} + +function kindOf(type: string): ContainerKind { + if (type === 'Array') return 'array'; + if (type === 'Map') return 'map'; + if (type === 'Set') return 'set'; + return 'object'; +} + +function isMap(value: Obj): boolean { + return value['$type'] === 'Map' && Array.isArray(value['entries']); +} + +function isSet(value: Obj): boolean { + return value['$type'] === 'Set' && Array.isArray(value['values']); +} + +function objectKeys(value: Obj): string[] { + return Object.keys(value).filter((key) => key !== '$type' && key !== '$more'); +} + +/** The container a value is, or `null` for a leaf (text, numbers, empty containers, markers). */ +export function containerOf(value: unknown): ValueContainer | null { + if (!value || typeof value !== 'object') return null; + if (isCollapsed(value)) { + if (value.size <= 0) return null; + return { + kind: kindOf(value.$collapsed), + type: value.$collapsed, + size: value.size, + collapsed: true, + }; + } + if (isMore(value)) return null; + if (Array.isArray(value)) { + const last = value.at(-1); + const more = isMore(last) ? last.$more : 0; + const size = value.length - (more ? 1 : 0) + more; + return size ? { kind: 'array', type: 'Array', size, collapsed: false } : null; + } + const obj = value as Obj; + if (isMap(obj) || isSet(obj)) { + const size = typeof obj['size'] === 'number' ? obj['size'] : 0; + const kind = isMap(obj) ? 'map' : 'set'; + return size ? { kind, type: kind === 'map' ? 'Map' : 'Set', size, collapsed: false } : null; + } + const more = typeof obj['$more'] === 'number' ? obj['$more'] : 0; + const size = objectKeys(obj).length + more; + const type = typeof obj['$type'] === 'string' ? obj['$type'] : 'Object'; + return size ? { kind: 'object', type, size, collapsed: false } : null; +} + +function keyText(key: unknown): string { + if (typeof key === 'string') return JSON.stringify(key); + const inner = containerOf(key); + if (inner) return summary(inner); + try { + return JSON.stringify(key) ?? String(key); + } catch { + return String(key); + } +} + +/** The listed entries of a level that the page read, and how many it left out. */ +export function childrenOf(value: unknown): { children: ValueChild[]; more: number } { + const container = containerOf(value); + if (!container || container.collapsed) return { children: [], more: 0 }; + if (Array.isArray(value)) { + const items = isMore(value.at(-1)) ? value.slice(0, -1) : value; + const children = items.map((item, i) => ({ key: String(i), label: String(i), value: item })); + return { children, more: container.size - children.length }; + } + const obj = value as Obj; + if (container.kind === 'map') { + const entries = obj['entries'] as unknown[]; + const children = entries.map((entry, i) => { + const [key, item] = Array.isArray(entry) ? entry : [undefined, entry]; + return { key: String(i), label: keyText(key), value: item }; + }); + return { children, more: Math.max(0, container.size - children.length) }; + } + if (container.kind === 'set') { + const values = obj['values'] as unknown[]; + const children = values.map((item, i) => ({ key: String(i), label: String(i), value: item })); + return { children, more: Math.max(0, container.size - children.length) }; + } + const children = objectKeys(obj).map((key) => ({ key, label: key, value: obj[key] })); + return { children, more: typeof obj['$more'] === 'number' ? obj['$more'] : 0 }; +} + +function plural(n: number, one: string, many: string): string { + return `${n} ${n === 1 ? one : many}`; +} + +/** `Array (4 items)`, `Address (2 keys)`, `Map (1 entry)`. */ +export function summary(container: ValueContainer): string { + const count = { + array: plural(container.size, 'item', 'items'), + object: plural(container.size, 'key', 'keys'), + map: plural(container.size, 'entry', 'entries'), + set: plural(container.size, 'value', 'values'), + }[container.kind]; + return `${container.type} (${count})`; +} + +/** The key of a level, the same as `valuePathKey()` on the page. */ +export function pathKey(path: readonly string[], directive?: number): string { + return JSON.stringify(directive === undefined ? path : [directive, ...path]); +} + +/** `user.name`, `items[0].title`, for labels. */ +export function shownPath(path: readonly string[], kinds?: readonly ContainerKind[]): string { + return path + .map((key, i) => { + if (i === 0) return key; + const indexed = kinds ? kinds[i - 1] !== 'object' : /^\d+$/.test(key); + return indexed ? `[${key}]` : `.${key}`; + }) + .join(''); +} + +/** + * The path a write takes (`user.name`, `items.0.title`), or `null` when a key + * cannot be written as one (it has a dot, a bracket or edge spaces). + */ +export function editPath(path: readonly string[]): string | null { + const ok = path.every((key) => key && key === key.trim() && !/[.[\]]/.test(key)); + return ok ? path.join('.') : null; +} + +const MARKER_TEXT = /^\[(Getter|Unreadable|Circular|Truncated|Function\b.*|Promise)\]$/; + +/** Whether a leaf holds a JSON value the panel can offer to replace. */ +export function editableLeaf(value: unknown): boolean { + if (value === null || typeof value === 'boolean') return true; + if (typeof value === 'number') return Number.isFinite(value); + if (typeof value !== 'string') return false; + // Text the page clipped ends with an ellipsis; editing it would drop the tail. + return value !== REDACTED && !MARKER_TEXT.test(value) && !value.endsWith('…'); +} + +/** + * The write that can reach inside `container`, given the one that reached + * it: a signal is set to a copy, which needs plain objects and arrays; a + * property is written in place, which a Map or Set entry cannot be. + */ +export function editInto( + kind: 'property' | 'signal' | null, + container: ValueContainer, +): 'property' | 'signal' | null { + if (!kind || container.kind === 'map' || container.kind === 'set') return null; + if (kind === 'signal' && container.kind === 'object' && container.type !== 'Object') return null; + return kind; +} + +/** + * Paths below `root` whose key contains `query`, among the levels already + * loaded: the first level the detail carries and the ones `levelAt` returns. + */ +export function nestedMatches( + root: readonly string[], + value: unknown, + levelAt: (path: readonly string[]) => unknown, + query: string, + limit = 500, +): string[][] { + const out: string[][] = []; + const needle = query.trim().toLowerCase(); + if (!needle) return out; + const seen = new Set(); + const walk = (path: readonly string[], level: unknown) => { + const key = pathKey(path); + if (seen.has(key) || out.length >= limit) return; + seen.add(key); + for (const child of childrenOf(level).children) { + const at = [...path, child.key]; + if (child.label.toLowerCase().includes(needle)) out.push(at); + if (!containerOf(child.value)) continue; + const loaded = levelAt(at) ?? (containerOf(child.value)?.collapsed ? undefined : child.value); + if (loaded !== undefined) walk(at, loaded); + } + }; + walk(root, levelAt(root) ?? value); + return out; +} diff --git a/app/src/pages/component-value-store.ts b/app/src/pages/component-value-store.ts new file mode 100644 index 00000000..6e6276b6 --- /dev/null +++ b/app/src/pages/component-value-store.ts @@ -0,0 +1,131 @@ +import { signal } from '@angular/core'; +import type { DevframeRpcClient } from 'devframe/client'; +import { FIRST_LIMIT, LOAD_STEP, MAX_LIMIT, pathKey } from './component-value-state'; + +/** Whether the level `key` is inside the level `parent` (both from `pathKey`). */ +function isBelow(key: string, parent: string): boolean { + return key.length > parent.length && key.startsWith(`${parent.slice(0, -1)},`); +} + +interface ValueAnswer { + ok?: boolean; + value?: unknown; + error?: string; +} + +export interface ValueTarget { + rpc: DevframeRpcClient | null; + pageId: string | null; + componentId: string | null; +} + +/** + * The nested value levels of the selected component: which ones are open, + * what each holds and how many entries it lists. The page reads an opened + * level again on every push (`detail.expanded`), which wins over the answer + * to the first request, so open levels follow the app. + */ +export class ValueStore { + private target: ValueTarget = { rpc: null, pageId: null, componentId: null }; + private readonly fetched = signal>(new Map()); + private readonly pushed = signal>>({}); + readonly open = signal>(new Set()); + readonly loading = signal>(new Set()); + readonly errors = signal>(new Map()); + private readonly limits = new Map(); + private generation = 0; + + /** Follows the selected component; another instance or page starts over. */ + follow(target: ValueTarget, expanded: Record | undefined) { + const same = + target.componentId === this.target.componentId && target.pageId === this.target.pageId; + this.target = target; + if (!same) { + this.generation++; + this.fetched.set(new Map()); + this.open.set(new Set()); + this.loading.set(new Set()); + this.errors.set(new Map()); + this.limits.clear(); + } + this.pushed.set(expanded ?? {}); + } + + /** The level at a path that the page sent, or `undefined` when none is loaded. */ + level(path: readonly string[], directive?: number): unknown { + const key = pathKey(path, directive); + const pushed = this.pushed(); + return Object.hasOwn(pushed, key) ? pushed[key] : this.fetched().get(key); + } + + isOpen(path: readonly string[], directive?: number): boolean { + return this.open().has(pathKey(path, directive)); + } + + /** Opens a level; `load` asks the page for it unless the detail already carries it. */ + toggle(path: readonly string[], directive: number | undefined, load: boolean) { + const key = pathKey(path, directive); + const open = new Set(this.open()); + if (open.has(key)) { + // The page stops reading the level and the ones below it, so they close too. + for (const other of open) if (other === key || isBelow(other, key)) open.delete(other); + this.open.set(open); + void this.send({ path, directive, forget: true }); + return; + } + open.add(key); + this.open.set(open); + if (load) void this.load(path, directive, this.limits.get(key) ?? FIRST_LIMIT); + } + + /** Lists the next entries of a level. */ + more(path: readonly string[], directive: number | undefined, listed: number) { + const key = pathKey(path, directive); + const limit = Math.min(MAX_LIMIT, Math.max(listed, this.limits.get(key) ?? 0) + LOAD_STEP); + this.limits.set(key, limit); + void this.load(path, directive, limit); + } + + /** Reads a value once, without keeping it open: the draft of a whole-value edit. */ + async read(path: readonly string[], depth: number, limit: number): Promise { + return this.send({ path, depth, limit }); + } + + private async load(path: readonly string[], directive: number | undefined, limit: number) { + const key = pathKey(path, directive); + const generation = this.generation; + this.loading.update((set) => new Set(set).add(key)); + const answer = await this.send({ path, directive, limit, watch: true }); + if (generation !== this.generation) return; + this.loading.update((set) => { + const next = new Set(set); + next.delete(key); + return next; + }); + this.errors.update((map) => { + const next = new Map(map); + if (answer.ok) next.delete(key); + else next.set(key, answer.error ?? 'The page did not read it.'); + return next; + }); + if (answer.ok) this.fetched.update((map) => new Map(map).set(key, answer.value)); + } + + private async send(request: Record): Promise { + const { rpc, pageId, componentId } = this.target; + if (!rpc || !pageId || !componentId) return { error: 'No live page to read from.' }; + const { directive, ...rest } = request; + try { + return ((await rpc.scope('pangular').rpc.call('request-component-value', { + pageId, + request: { + id: componentId, + ...rest, + ...(directive === undefined ? {} : { directive }), + }, + })) ?? {}) as ValueAnswer; + } catch { + return { error: 'The devtools server did not answer. Try again.' }; + } + } +} diff --git a/app/src/pages/component-value-tree.ts b/app/src/pages/component-value-tree.ts new file mode 100644 index 00000000..11b2b4b5 --- /dev/null +++ b/app/src/pages/component-value-tree.ts @@ -0,0 +1,491 @@ +import { Component, ElementRef, afterRenderEffect, computed, inject, input } from '@angular/core'; +import type { DevframeRpcClient } from 'devframe/client'; +import { PropValue, type EditableProp } from './component-prop-value'; +import { + EDIT_READ, + LOAD_STEP, + childrenOf, + containerOf, + editInto, + editPath, + editableLeaf, + nestedMatches, + pathKey, + shownPath, + summary, + type ContainerKind, + type ValueContainer, +} from './component-value-state'; +import type { ValueStore } from './component-value-store'; + +export interface TreeProp extends EditableProp { + editInside?: 'property' | 'signal'; +} + +type EditKind = 'property' | 'signal' | null; + +interface Row { + id: string; + kind: 'node' | 'leaf' | 'more' | 'loading' | 'error'; + depth: number; + path: string[]; + label: string; + /** Leaf text, or the node summary. */ + text: string; + container?: ValueContainer; + open?: boolean; + /** Entries left out of the level, and how many it lists. */ + more?: number; + listed?: number; + /** A leaf that can be written, as PropValue edits it. */ + edit?: EditableProp; +} + +let nextId = 0; + +function leafText(value: unknown): string { + if (value === undefined) return 'undefined'; + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + +/** + * Whether a value the page read for a whole edit holds all of it: no level + * left collapsed or cut (`$collapsed`, `$more`), no Map, Set or class marker + * (`$type`), and only leaves a nested edit could write too, so clipped text, + * redacted values and markers such as `[Truncated]` are not written back. + */ +function wholeValue(value: unknown): boolean { + if (Array.isArray(value)) return value.every(wholeValue); + if (value && typeof value === 'object') { + const obj = value as Record; + if (['$collapsed', '$more', '$type'].some((key) => Object.hasOwn(obj, key))) return false; + return Object.values(obj).every(wholeValue); + } + return editableLeaf(value); +} + +/** + * One input or property value of the selected component. Text, numbers and + * other leaves show as before; an object, array, Map or Set shows its first + * level behind a toggle, and each nested level is read from the page when it + * is opened. "Show more" lists the next entries of a long level. With a + * filter, only the loaded keys that match it and their parents show. + */ +@Component({ + selector: 'app-value-tree', + imports: [PropValue], + template: ` + @if (root(); as c) { +
+ + +
+ @if (rootOpen()) { +
    + @for (row of rows(); track row.id) { +
  • + @switch (row.kind) { + @case ('node') { + + } + @case ('leaf') { + {{ row.label }}: + @if (row.edit; as edit) { + + } @else { + {{ row.text }} + } + } + @case ('more') { + + } + @case ('loading') { + Reading from the page… + } + @case ('error') { + {{ row.text }} + } + } +
  • + } +
+ } + } @else { + + } + `, + styles: ` + @use 'mixins' as m; + :host { + display: block; + min-width: 0; + } + .root { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 8px; + } + .whole { + flex: 1 1 auto; + } + .entries { + margin: 4px 0 0; + padding: 0 0 0 6px; + border-left: 1px solid var(--border); + list-style: none; + } + .entry { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px; + min-width: 0; + padding-top: 2px; + padding-bottom: 2px; + font-size: 12px; + line-height: 1.5; + } + .toggle, + .more { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 24px; + max-width: 100%; + padding: 0 6px 0 2px; + background: none; + border: 1px solid transparent; + border-radius: var(--radius-sm); + color: var(--text); + cursor: pointer; + font: inherit; + font-size: 12px; + text-align: left; + transition: background-color 150ms var(--ease); + } + .toggle:hover, + .more:hover { + background: var(--surface-2); + } + .toggle:focus-visible, + .more:focus-visible { + @include m.focus-ring; + } + .more { + padding-left: 6px; + color: var(--text); + text-decoration: underline; + } + .chevron { + flex: none; + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + transition: transform 150ms var(--ease); + } + .toggle[aria-expanded='true'] .chevron { + transform: rotate(90deg); + } + .key { + flex: none; + max-width: 50%; + overflow-wrap: anywhere; + color: var(--text-2); + } + .summary { + color: var(--text-2); + } + .leaf { + min-width: 0; + color: var(--text); + overflow-wrap: anywhere; + } + .leaf-edit { + flex: 1 1 160px; + } + .muted { + color: var(--text-2); + } + .error { + color: var(--danger-text); + } + @media (prefers-reduced-motion: reduce) { + .chevron, + .toggle, + .more { + transition: none; + } + } + `, +}) +export class ValueTree { + readonly prop = input.required(); + readonly store = input.required(); + readonly componentId = input.required(); + readonly pageId = input(null); + readonly rpc = input(null); + /** Whether this panel may write: live page, server connected, `actions.components` on. */ + readonly writes = input(false); + /** Index of the host directive whose input this is; its values are read-only here. */ + readonly directive = input(undefined); + /** The detail's property filter. */ + readonly query = input(''); + + protected readonly listId = `value-tree-${++nextId}`; + + private readonly host = inject>(ElementRef); + + /** Rows listed when "Show more" was pressed, until the next entries render. */ + private pendingMore: Set | null = null; + + constructor() { + // "Show more" goes away once the last entries are listed; keep focus in the list. + afterRenderEffect(() => { + const rows = this.rows(); + const before = this.pendingMore; + if (!before) return; + const added = rows.findIndex( + (row) => (row.kind === 'leaf' || row.kind === 'node') && !before.has(row.id), + ); + if (added < 0) return; + this.pendingMore = null; + const active = document.activeElement; + if (active && active !== document.body && active.isConnected) return; + this.focusNear(added); + }); + } + + private readonly rootPath = computed(() => [this.prop().prop]); + + /** The first level: the one the detail carries, or a longer one the panel loaded. */ + private readonly rootValue = computed( + () => this.store().level(this.rootPath(), this.directive()) ?? this.prop().value, + ); + + readonly root = computed(() => containerOf(this.rootValue())); + + private readonly needle = computed(() => this.query().trim().toLowerCase()); + + /** Nested paths whose key matches the filter, when the name itself does not. */ + private readonly matches = computed(() => { + const needle = this.needle(); + const prop = this.prop(); + if (!needle || [prop.name, prop.prop].some((n) => n.toLowerCase().includes(needle))) { + return null; + } + const store = this.store(); + const directive = this.directive(); + return nestedMatches( + this.rootPath(), + this.rootValue(), + (path) => store.level(path, directive), + needle, + ); + }); + + readonly rootOpen = computed( + () => !!this.matches()?.length || this.store().isOpen(this.rootPath(), this.directive()), + ); + + readonly rows = computed(() => { + const root = this.root(); + if (!root) return []; + const store = this.store(); + const directive = this.directive(); + const matches = this.matches(); + const shown = new Set(); + const hits = new Set(); + for (const match of matches ?? []) { + hits.add(pathKey(match)); + for (let i = 2; i <= match.length; i++) shown.add(pathKey(match.slice(0, i))); + } + const prop = this.prop(); + const writes = this.writes() && directive === undefined; + const out: Row[] = []; + const walk = ( + level: unknown, + path: string[], + kinds: ContainerKind[], + depth: number, + edit: EditKind, + inHit: boolean, + ) => { + const { children, more } = childrenOf(level); + for (const child of children) { + const at = [...path, child.key]; + const key = pathKey(at); + if (matches && !inHit && !shown.has(key)) continue; + const container = containerOf(child.value); + const id = pathKey(at, directive); + if (!container) { + const writable = edit && editableLeaf(child.value) ? editPath(at) : null; + out.push({ + id, + kind: 'leaf', + depth, + path: at, + label: child.label, + text: leafText(child.value), + edit: + writes && writable && edit + ? { name: shownPath(at, [...kinds]), prop: writable, value: child.value, edit } + : undefined, + }); + continue; + } + // The filter opens the levels that lead to a match. + const forced = !!matches && !inHit && shown.has(key) && !hits.has(key); + const open = forced || store.isOpen(at, directive); + out.push({ + id, + kind: 'node', + depth, + path: at, + label: child.label, + text: summary(container), + container, + open, + }); + if (!open) continue; + const loaded = + store.level(at, directive) ?? (container.collapsed ? undefined : child.value); + const next = [...kinds, container.kind]; + if (loaded !== undefined) { + walk(loaded, at, next, depth + 1, editInto(edit, container), inHit || hits.has(key)); + } else if (store.errors().has(id)) { + out.push({ ...stub(at, depth + 1, 'error'), text: store.errors().get(id)! }); + } else { + out.push(stub(at, depth + 1, 'loading')); + } + } + if (more > 0 && (!matches || inHit)) { + out.push({ ...stub(path, depth, 'more'), more, listed: children.length }); + } + }; + const stub = (at: string[], depth: number, kind: Row['kind']): Row => ({ + id: `${pathKey(at, directive)}:${kind}`, + kind, + depth, + path: at, + label: '', + text: '', + }); + walk( + this.rootValue(), + this.rootPath(), + [root.kind], + 0, + writes ? editInto(prop.editInside ?? null, root) : null, + !matches, + ); + return out; + }); + + /** Reads the whole value for the editor, as deep as the page allows a whole edit. */ + protected readonly readWhole = async () => { + const answer = await this.store().read(this.rootPath(), EDIT_READ.depth, EDIT_READ.limit); + if (!answer.ok) return { error: answer.error ?? 'The page did not read it.' }; + if (!wholeValue(answer.value)) { + return { error: 'Too large to edit as a whole. Open it to edit the values inside.' }; + } + return { value: answer.value }; + }; + + protected summaryOf(container: ValueContainer): string { + return summary(container); + } + + protected toggleRoot() { + this.pendingMore = null; + this.store().toggle(this.rootPath(), this.directive(), false); + } + + protected toggle(row: Row) { + this.pendingMore = null; + this.store().toggle(row.path, this.directive(), !!row.container?.collapsed); + } + + protected more(row: Row) { + this.pendingMore = new Set(this.rows().map((r) => r.id)); + this.store().more(row.path, this.directive(), row.listed ?? 0); + } + + /** + * Focuses the first control in the entry at `index` or after it (a toggle, + * an Edit button, the next "Show more"), else the closest one before it, + * else the root toggle. + */ + private focusNear(index: number) { + const root = this.host.nativeElement; + const entries = Array.from(root.querySelectorAll(`#${this.listId} > li`)); + const control = (entry: HTMLElement | undefined) => + entry?.querySelector('button:not([disabled]), input, textarea, [tabindex="0"]'); + const target = + entries.slice(index).map(control).find(Boolean) ?? + entries.slice(0, index).reverse().map(control).find(Boolean) ?? + root.querySelector('.root > .toggle'); + target?.focus(); + } + + protected step(row: Row): number { + return Math.min(LOAD_STEP, row.more ?? 0); + } +} diff --git a/app/src/pages/hydration-section.ts b/app/src/pages/hydration-section.ts new file mode 100644 index 00000000..f7e766f1 --- /dev/null +++ b/app/src/pages/hydration-section.ts @@ -0,0 +1,275 @@ +import { Component, computed, input, linkedSignal, output } from '@angular/core'; + +export interface HydrationMismatch { + id: string; + name: string; + tag: string; + expected?: string; + actual?: string; +} + +/** What a page reports about hydration; missing when it was not rendered on the server. */ +export interface HydrationReport { + hydrated: boolean; + counts: { + hydrated: number; + skipped: number; + mismatched: number; + client: number; + dehydratedBlocks: number; + }; + mismatches: HydrationMismatch[]; + overlay?: boolean; +} + +/** + * The hydration summary of the Components page: counts by status, the + * mismatches, and the switch for the outline the overlay draws on the page. + */ +@Component({ + selector: 'app-hydration-section', + template: ` +
+

Hydration

+ @if (report(); as r) { + @if (!r.hydrated) { +

+ The HTML came from the server, but no component was hydrated, so the app rendered again + on the client. Add provideClientHydration() and use a development build to + see each component's status. +

+ } @else { +
+
    + @for (item of counts(); track item.key) { +
  • + {{ item.value }} {{ item.label }} +
  • + } +
+ +
+ @if (r.mismatches.length) { +

Mismatches

+
    + @for (m of r.mismatches; track m.id) { +
  • + +
  • + } +
+ } + } + } @else { +

+ This page was not rendered on the server, so there is nothing to hydrate. With server-side + rendering and provideClientHydration(), each component shows whether it was + hydrated, skipped or mismatched. +

+ } +
+ `, + styles: ` + @use 'mixins' as m; + + .hydration { + margin-top: 16px; + } + .title { + @include m.label; + margin: 0 0 8px; + } + .label { + @include m.label; + margin: 12px 0 6px; + } + .empty { + margin: 0; + padding: 10px 12px; + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + color: var(--text-2); + line-height: 1.5; + } + code, + .mono { + font-family: var(--font-mono); + } + code { + font-size: 12px; + overflow-wrap: anywhere; + } + .muted { + color: var(--text-2); + } + .summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + } + .counts { + display: flex; + flex: 1 1 auto; + flex-wrap: wrap; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; + } + .count { + padding: 2px 10px; + border: 1px solid var(--border-strong); + border-radius: 99px; + color: var(--text); + font-size: 12px; + } + .num { + font-weight: 600; + font-variant-numeric: tabular-nums; + } + .count-hydrated { + @include m.tint(ok); + } + .count-skipped, + .count-dehydratedBlocks { + @include m.tint(warn); + } + .count-mismatched { + @include m.tint(danger); + } + button { + display: inline-flex; + align-items: center; + gap: 8px; + height: var(--control-h); + padding: 0 14px; + background: var(--surface-2); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 500; + transition: + background-color 150ms var(--ease), + border-color 150ms var(--ease); + } + button:hover { + background: var(--surface-3); + border-color: var(--accent-line); + } + button:focus-visible { + @include m.focus-ring; + } + .outline[aria-pressed='true'] { + background: var(--accent-soft); + border-color: var(--accent-line); + color: var(--text-strong); + } + svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2.2; + } + .mismatches { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; + } + .mismatch { + flex-wrap: wrap; + justify-content: flex-start; + width: 100%; + height: auto; + min-height: var(--control-h); + padding: 6px 12px; + background: var(--surface); + border-color: var(--border); + text-align: left; + } + .name { + color: var(--text-strong); + font-size: 12px; + font-weight: 600; + } + .detail-line { + width: 100%; + color: var(--text-2); + font-size: 12px; + } + @media (prefers-reduced-motion: reduce) { + button { + transition: none; + } + } + `, +}) +export class HydrationSection { + readonly report = input(undefined); + readonly overlay = output(); + readonly showNode = output(); + readonly highlight = output(); + + /** Follows the page, and shows a click at once while the page catches up. */ + readonly overlayOn = linkedSignal(() => this.report()?.overlay === true); + + readonly counts = computed(() => { + const counts = this.report()?.counts; + if (!counts) return []; + return [ + { key: 'hydrated', label: 'hydrated', value: counts.hydrated }, + { key: 'skipped', label: 'skipped', value: counts.skipped }, + { key: 'mismatched', label: 'mismatched', value: counts.mismatched }, + { key: 'client', label: 'created on the client', value: counts.client }, + { + key: 'dehydratedBlocks', + label: + counts.dehydratedBlocks === 1 ? 'dehydrated @defer block' : 'dehydrated @defer blocks', + value: counts.dehydratedBlocks, + }, + ]; + }); + + toggleOverlay() { + const next = !this.overlayOn(); + this.overlayOn.set(next); + this.overlay.emit(next); + } +} diff --git a/app/src/pages/signal-inspector.ts b/app/src/pages/signal-inspector.ts index 564723b5..9c07eb11 100644 --- a/app/src/pages/signal-inspector.ts +++ b/app/src/pages/signal-inspector.ts @@ -90,6 +90,8 @@ interface LiveNode { id: string; name: string; tag: string; + /** `directive` for an element that hosts directives but no component. */ + kind?: 'directive'; children: LiveNode[]; } @@ -1856,7 +1858,8 @@ export class SignalInspector { const flat: LiveNode[] = []; const walk = (nodes: LiveNode[]) => { for (const node of nodes) { - flat.push(node); + // A directive host has no component signals to read. + if (node.kind !== 'directive') flat.push(node); walk(node.children); } }; diff --git a/apps/docs/src/content/agents/resources.md b/apps/docs/src/content/agents/resources.md index 0b5b52ca..b9ef96ee 100644 --- a/apps/docs/src/content/agents/resources.md +++ b/apps/docs/src/content/agents/resources.md @@ -50,7 +50,7 @@ When one of the first four runs past 100,000 characters, it returns a summary in ### component-tree -The component instances of each page, under `pages[pageId].roots`. Each node has an instance id, class name, host tag and the directives on its host. `detail` holds the live inputs, outputs, other properties, listeners, change detection, encapsulation and injected dependencies of the selected instance. The selection is the one made in the panel, by clicking in the page with **Pick component on page**, or with the `highlight` or `inspect-component` tool. `truncated` is `true` when the page has more instances than it lists, and `truncatedBy` names the limit it hit: `components` (2000 instances) or `depth` (256 levels of DOM nesting). `deferBlocks` lists the page's `@defer` blocks, and is missing when the page has no util to read them. `nodes` repeats the roots of the most recent page. +The component instances of each page, under `pages[pageId].roots`. Each node has an instance id, class name, host tag, the directives on its host, and `attributes`, the selector attribute names of those directives. In a page rendered on the server, each node also has its `hydration` status: `hydrated`, `skipped`, `mismatched` or `client`. An element that hosts directives but no component is a node too, with `kind: "directive"`, all of its directives in `directives`, and the first one as `name`. `platform` is `angular-native` for an Angular Native app, and missing for a browser page. `count` counts the component instances and `directiveHostCount` the directive hosts. `hydration` reports the page's hydration: counts by status, dehydrated `@defer` blocks, mismatches between what Angular expected and found, and whether the hydration overlay is on. It is missing when the page was not rendered on the server. `detail` holds the live inputs, outputs, other properties, listeners, change detection, encapsulation and injected dependencies of the selected instance, and `source`, the project-relative `file` and 1-based `line` of its class when a dev build has the debug info of Angular. `detail.properties` lists the other own fields, with signals and resources unwrapped. Each value shows its first level, with nested objects, arrays, `Map`s and `Set`s as `{"$collapsed": type, "size": n}`; read them with `inspect-component` and `path`. `detail.expanded` holds the nested levels opened in the panel. The selection is the one made in the panel, by clicking in the page with **Pick component on page**, or with the `highlight` or `inspect-component` tool. `truncated` is `true` when the page has more instances than it lists, and `truncatedBy` names the limit it hit: `components` (2000 instances), `depth` (256 levels of DOM nesting) or `directiveHosts` (1000 elements that host directives but no component). `deferBlocks` lists the page's `@defer` blocks, and is missing when the page has no util to read them. `nodes` repeats the roots of the most recent page. The instance ids here are what `highlight` and `inspect-component` accept. diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index 7a27ff32..01fd217a 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -4,7 +4,7 @@ description: Every agent tool the devtools expose, grouped by inspector, with wh --- - Sixty-six tools, grouped by inspector. Each one answers a question you would otherwise answer by clicking through the panel. + Sixty-seven tools, grouped by inspector. Each one answers a question you would otherwise answer by clicking through the panel. # Tools @@ -35,7 +35,7 @@ Each tool reads from one of three places. The stdio server registers only the to ### The page argument -Most page tools take an optional `page` argument to pick a browser tab. It defaults to the most recent one. `dispatch-ngrx-action`, `set-component-property` and `set-signal` refuse to guess when more than one tab could take the action. `form-action` and `fill-form` take the tab from the form id instead (the part after `@`, as in `Checkout.form@ab12`). `highlight` and `inspect-component` search every tab without it, newest first, and `defer-blocks` and `for-blocks` list every tab. `inspect-providers`, `highlight`, `inspect-component`, `defer-blocks` and `for-blocks` also accept `pageId`. The tables below leave `page` out. +Most page tools take an optional `page` argument to pick a browser tab. It defaults to the most recent one. `dispatch-ngrx-action`, `set-component-property` and `set-signal` refuse to guess when more than one tab could take the action. `form-action` and `fill-form` take the tab from the form id instead (the part after `@`, as in `Checkout.form@ab12`). `highlight` and `inspect-component` search every tab without it, newest first, and `defer-blocks`, `for-blocks` and `hydration` list every tab. `inspect-providers`, `highlight`, `inspect-component`, `defer-blocks`, `for-blocks` and `hydration` also accept `pageId`. The tables below leave `page` out. If `page` names a tab that doesn't report that data, the tool answers `No page is reporting ...` and lists the tabs that do, newest first. It never falls back to another tab. Without `page`, a class name or tag resolves on the most recent tab that has it, and `highlight` names the other tabs where it also matches. @@ -49,21 +49,21 @@ The URL comes from the HTTP or router report. A page that sends neither (no rout Every tool has one of three safety tiers. The server sends the tier to your client as the tool's read-only and destructive hints. -| Tier | What the tool can do | Tools | -| ------------- | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `read` | Reads your source, the page or the server. `highlight` only shows and selects a component. | Every other tool, and every resource | -| `action` | Changes the running page, the server's fault rules or what the page records. A reload or the next call undoes it. | `navigate`, `dispatch-ngrx-action`, `form-action`, `fill-form`, `set-http-rule`, `remove-http-rule`, `set-component-property`, `set-signal`, `watch-signal`, `record-pipes` and `change-detection` | -| `destructive` | Can lose data: it erases recordings, or sends writes to your own API routes. | `clear-recordings` and `analog-call-api` | +| Tier | What the tool can do | Tools | +| ------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `read` | Reads your source, the page or the server. `highlight` only shows and selects a component. | Every other tool, and every resource | +| `action` | Changes the running page, the server's fault rules or what the page records. A reload or the next call undoes it. | `navigate`, `dispatch-ngrx-action`, `form-action`, `fill-form`, `set-http-rule`, `remove-http-rule`, `set-component-property`, `set-signal`, `watch-signal`, `record-pipes`, `change-detection` and `hydration` | +| `destructive` | Can lose data: it erases recordings, or sends writes to your own API routes. | `clear-recordings` and `analog-call-api` | - The thirteen action and destructive tools act on the app. Your client can ask you before it runs them. + The fourteen action and destructive tools act on the app. Your client can ask you before it runs them. -When the hub is mounted, each finished call of an `action` or `destructive` tool posts an `Agent ran ` 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). +When the hub is mounted, each finished call of an `action` or `destructive` tool posts an `Agent ran ` 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, and `hydration` only when it turns the overlay on or off. See [Messages and badges](../getting-started/popup-and-hub.md#messages-and-badges). ### Turn tools off -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.` 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). +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. `hydration` stays as a `read` tool without its `overlay` argument. Set `agent.tools.` 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). ## Source scan @@ -99,20 +99,26 @@ The live detail of one component instance: inputs, outputs and whether a parent | Argument | Required | Value | | ---------- | -------- | ---------------------------------------------------------------------------- | | `selector` | yes | An instance id from the `component-tree` resource, a class name or host tag. | +| `path` | no | One value to read instead of the whole detail, such as `user.address`. | +| `depth` | no | With `path`: levels to read below it, 1 to 5. Defaults to 1. | +| `limit` | no | With `path`: most entries per object or list, 1 to 1000. Defaults to 30. | | `pageId` | no | Same as `page`. | -A class name or tag that matches several instances answers for the first and lists the ids of all of them. When nothing matches and the page's tree stopped at a [limit](../inspectors/components.md#selection), `inspect-component` and `highlight` say which one, since the instance can be past it. Secret-looking values are redacted. +A class name or tag that matches several instances answers for the first and lists the ids of all of them. A class name or tag only finds components. The id of a directive host from `list-components` answers with the directives on that element, since it has no component detail. When nothing matches and the page's tree stopped at a [limit](../inspectors/components.md#selection), `inspect-component` and `highlight` say which one, since the instance can be past it. Secret-looking values are redacted. + +Each value in the detail shows its first level. A nested object, array, `Map` or `Set` reads `{"$collapsed": type, "size": n}`, and a list cut at 30 entries ends with a `$more` count. Pass `path` to read that value: an input or property name, then keys and indexes, as in `user.address` or `items[0]`. An index into a `Map` or `Set` counts its entries. With `path`, the tool reads the value without changing the selection. It reads own data properties only, so getters never run, and it refuses a path through a redacted name. Each input and property carries `editInside` when a path inside it can be written with `set-component-property` or `set-signal`. The answer stops at 20,000 characters. Past that, each value longer than 1,000 characters shows only the start of its JSON, then each list (inputs, outputs, properties, listeners, directives, injected services, and the inputs and outputs of each directive) keeps fewer entries. A note names the cut values and says how many entries are not shown. The `detail` of the `component-tree` resource has every value in full. ### list-components -The live component tree of one page as an indented outline, one line per instance: class name, host tag and instance id, as in `ProductCard c12`. Reads: page. +The live component tree of one page as an indented outline, one line per instance: class name, host tag and instance id, as in `ProductCard c12`. An element that hosts directives but no component gets a line too, as in `
[routerLink] c14 [directive host: RouterLink]`. Reads: page. -| Argument | Required | Value | -| -------- | -------- | --------------------------------------------------------------------------------------------------------------------------- | -| `filter` | no | Text to find in a class name, host tag or host directive, ignoring case. The outline keeps the matches and their ancestors. | -| `depth` | no | The most levels to list, `1` for the roots only. Unlimited by default. | +| Argument | Required | Value | +| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `filter` | no | A search, as in the [Components](../inspectors/components.md#search-syntax) filter: plain text, `` and `[directive]`, such as `