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
62 changes: 61 additions & 1 deletion app/src/__tests__/performance-build-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ import {
perfPages,
type PerfPage,
} from '../pages/performance-inspector';
import { BuildInspector, formatBytes, type BuildGraph } from '../pages/build-inspector';
import {
BuildInspector,
formatBytes,
formatMs,
timeBreakdown,
type BuildGraph,
} from '../pages/build-inspector';

function client(options: { state?: unknown; call?: (name: string) => unknown }) {
const call = vi.fn(async (name: string) => options.call?.(name));
Expand Down Expand Up @@ -172,4 +178,58 @@ describe('Build tab', () => {
it('formats bytes', () => {
expect(formatBytes(4200)).toBe('4.2 kB');
});

it('shows the time per module and sorts the module list by time', async () => {
const timed: BuildGraph = {
...graph,
vite: {
...graph.vite!,
modules: graph.vite!.modules.map((mod, i) =>
i === 0 ? mod : { ...mod, time: { resolve: 0.2, load: 1, transform: i, total: i + 1.2 } },
),
timings: { modules: 29, capped: false },
},
};
const { host } = await render(timed);
const table = () => host.querySelectorAll('section')[0]!.querySelectorAll('table')[1]!;
const firstCells = () =>
[...table().querySelectorAll('tbody tr')].slice(0, 2).map((row) => {
const cells = row.querySelectorAll('td');
return [cells[0]!.textContent!.trim(), cells[2]!.textContent!.trim()];
});
expect(host.textContent).toContain('Time is what the dev server spent on 29 modules');
// Biggest first, as the server sends them; m0 was not timed.
expect(firstCells()).toEqual([
['src/m0.ts', ''],
['src/m1.ts', '2.2 ms'],
]);
expect(table().querySelectorAll('tbody tr td')[7]!.getAttribute('title')).toBe(
'Resolve 0.2 ms, load 1.0 ms, transform 1.0 ms',
);
const timeHeader = [...table().querySelectorAll('th')].find((th) =>
th.textContent!.includes('Time'),
)!;
const sortButton = timeHeader.querySelector('button')!;
sortButton.click();
await fixture.whenStable();
expect(timeHeader.getAttribute('aria-sort')).toBe('ascending');
expect(firstCells()[0]).toEqual(['src/m1.ts', '2.2 ms']);
sortButton.click();
await fixture.whenStable();
expect(timeHeader.getAttribute('aria-sort')).toBe('descending');
expect(firstCells()[0]).toEqual(['src/m29.ts', '30.2 ms']);
// The module without a time stays last in both directions.
[...host.querySelectorAll('button')].find((b) => b.textContent!.includes('Show all'))!.click();
await fixture.whenStable();
const rows = table().querySelectorAll('tbody tr');
expect(rows[rows.length - 1]!.querySelector('td')!.textContent!.trim()).toBe('src/m0.ts');
});

it('formats milliseconds and the time breakdown', () => {
expect(formatMs(12.34)).toBe('12.3 ms');
expect(formatMs(2500)).toBe('2.50 s');
expect(timeBreakdown({ resolve: 0.1, load: 2, transform: 40.5, total: 42.6 })).toBe(
'Resolve 0.1 ms, load 2.0 ms, transform 40.5 ms',
);
});
});
56 changes: 49 additions & 7 deletions app/src/pages/build-inspector.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Component, computed, effect, input, signal } from '@angular/core';
import type { DevframeRpcClient } from 'devframe/client';
import { SearchField } from '../ui/search-field';
import { SortHeader, sortRows, type SortState } from '../ui/sort-header';

/** One module of the Vite dev server's graph, as `build-graph.ts` sends it. */
export interface GraphModule {
Expand All @@ -11,6 +12,8 @@ export interface GraphModule {
importers: number;
imports: number;
dynamicImports: string[];
/** Wall-clock milliseconds the dev server spent on it, when the Vite plugin timed it. */
time?: { resolve: number; load: number; transform: number; total: number };
}

export interface LazyBoundary {
Expand Down Expand Up @@ -38,6 +41,7 @@ export interface BuildGraph {
entries: string[];
initialSize: number;
lazy: LazyBoundary[];
timings?: { modules: number; capped: boolean };
};
viteNote?: string;
output?: {
Expand All @@ -59,12 +63,22 @@ export function formatBytes(bytes: number): string {
return `${(bytes / 1_000_000).toFixed(2)} MB`;
}

/** `12.34` as `12.3 ms`, `2500` as `2.50 s`. */
export function formatMs(ms: number): string {
return ms < 1000 ? `${ms.toFixed(1)} ms` : `${(ms / 1000).toFixed(2)} s`;
}

/** What the dev server spent on a module, phase by phase. */
export function timeBreakdown(time: NonNullable<GraphModule['time']>): string {
return `Resolve ${formatMs(time.resolve)}, load ${formatMs(time.load)}, transform ${formatMs(time.transform)}`;
}

/** Rows shown per table before **Show all**. */
export const BUILD_ROWS = 25;

@Component({
selector: 'app-build-inspector',
imports: [SearchField],
imports: [SearchField, SortHeader],
template: `
<p class="intro">
What the app loads: the Vite dev server's module graph with its lazy boundaries, and the
Expand All @@ -90,6 +104,14 @@ export const BUILD_ROWS = 25;
only what the browser requested). {{ bytes(vite.initialSize) }} load without
<code>import()</code> from {{ vite.entries.length }}
{{ vite.entries.length === 1 ? 'entry' : 'entries' }}.
@if (vite.timings; as timings) {
Time is what the dev server spent on {{ timings.modules }}
{{ timings.modules === 1 ? 'module' : 'modules' }}, wall clock: resolving imports to
it, and its last load and transform through all plugins.
@if (timings.capped) {
The oldest timings were dropped.
}
}
</p>
<h3>Lazy boundaries</h3>
@if (!vite.lazy.length) {
Expand Down Expand Up @@ -149,14 +171,19 @@ export const BUILD_ROWS = 25;
<div class="scroll">
<table>
<caption class="sr-only">
Modules, biggest first
Modules, biggest first unless sorted
</caption>
<thead>
<tr>
<th scope="col">Module</th>
<th scope="col" class="num">Size</th>
<th scope="col" class="num">Importers</th>
<th scope="col" class="num">Imports</th>
<th scope="col" appSortHeader="module" [(sort)]="moduleSort">Module</th>
<th scope="col" class="num" appSortHeader="size" [(sort)]="moduleSort">Size</th>
<th scope="col" class="num" appSortHeader="time" [(sort)]="moduleSort">Time</th>
<th scope="col" class="num" appSortHeader="importers" [(sort)]="moduleSort">
Importers
</th>
<th scope="col" class="num" appSortHeader="imports" [(sort)]="moduleSort">
Imports
</th>
</tr>
</thead>
<tbody>
Expand All @@ -166,6 +193,11 @@ export const BUILD_ROWS = 25;
<code>{{ row.file ?? row.url }}</code>
</td>
<td class="num">{{ bytes(row.size) }}</td>
@if (row.time; as time) {
<td class="num" [title]="breakdown(time)">{{ ms(time.total) }}</td>
} @else {
<td class="num"></td>
}
<td class="num">{{ row.importers }}</td>
<td class="num">{{ row.imports }}</td>
</tr>
Expand Down Expand Up @@ -347,20 +379,30 @@ export class BuildInspector {
readonly rpc = input<DevframeRpcClient | null>(null);

protected readonly bytes = formatBytes;
protected readonly ms = formatMs;
protected readonly breakdown = timeBreakdown;

readonly graph = signal<BuildGraph | null>(null);
readonly loading = signal(false);
readonly failed = signal(false);
readonly status = signal('');
readonly filter = signal('');
readonly allModules = signal(false);
readonly moduleSort = signal<SortState | null>(null);

readonly shownModules = computed(() => {
const modules = this.graph()?.vite?.modules ?? [];
const text = this.filter().trim().toLowerCase();
return text
const matching = text
? modules.filter((mod) => `${mod.url} ${mod.file ?? ''}`.toLowerCase().includes(text))
: modules;
return sortRows(matching, this.moduleSort(), {
module: (mod) => mod.file ?? mod.url,
size: (mod) => mod.size,
time: (mod) => mod.time?.total,
importers: (mod) => mod.importers,
imports: (mod) => mod.imports,
});
});
readonly visibleModules = computed(() =>
this.allModules() ? this.shownModules() : this.shownModules().slice(0, BUILD_ROWS),
Expand Down
8 changes: 4 additions & 4 deletions apps/docs/src/content/agents/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -1055,10 +1055,10 @@ A metric the browser doesn't measure, or has no entry for yet, says `none yet`,

These tools read what the app loads. See [Build](../inspectors/build.md).

| Tool | What it answers | Arguments |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- |
| `module-graph` | The Vite dev server's module graph: the entries, the lazy boundaries (`import()`) with the modules and size each pulls in, and the biggest modules. With `search`, matching modules with their importers, imports and `import()` calls. | `search`, `limit` |
| `chunk-sizes` | The chunks of the last production build: initial or lazy, size, gzip size, and with `stats.json` the entry point. | `limit` |
| Tool | What it answers | Arguments |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- |
| `module-graph` | The Vite dev server's module graph: the entries, the lazy boundaries (`import()`) with the modules and size each pulls in, the biggest modules, and the slowest modules with the wall-clock time Vite spent resolving, loading and transforming each. With `search`, matching modules with their size, time, importers, imports and `import()` calls. | `search`, `limit` |
| `chunk-sizes` | The chunks of the last production build: initial or lazy, size, gzip size, and with `stats.json` the entry point. | `limit` |

`module-graph` needs the Vite plugin, in the dev server's process; it reads nothing on the stdio MCP server. Sizes are transformed, unminified code, for modules the browser requested. `chunk-sizes` reads the build output on disk, so it works anywhere the project is, and says how to build when there is none. `limit` defaults to 20 for `module-graph` (at most 200) and 30 for `chunk-sizes` (at most 500).

Expand Down
Loading
Loading