Perfetto UI Development for AI Agents

Perfetto UI is a Single Page Web Application written in TypeScript using the Mithril framework. It lives in ui/ and powers ui.perfetto.dev. The UI embeds TraceProcessor via WebAssembly.

General Principles

  • Don't over-engineer - Solve the problem at hand, not hypothetical future problems.
  • Prefer simpler approaches - If there's a simple solution and a complex one, choose simple.
  • Search before creating - Always search for existing utilities before writing new ones.
  • Be consistent - Follow the patterns established in the surrounding code.
  • Prefer interfaces with immutable readonly members - We like immutability, makes the code easier to debug.

Directory Structure

The UI codebase is organized as follows:

ui/src/
├── base/           # Core utilities (time, color, arrays, logging, disposables)
├── widgets/        # Reusable UI components (Button, Menu, Modal, Popup, etc.)
├── components/     # Higher-level components (aggregation panels, query tables)
├── core/           # Core application logic and managers
├── public/         # Public API surface for plugins
├── plugins/        # Optional third-party/external plugins
├── core_plugins/   # Essential core plugins (cannot be disabled)
├── frontend/       # Main frontend rendering code
├── trace_processor/# Engine communication layer (query results, SQL utilities)
├── test/           # Playwright integration tests
└── assets/         # SCSS stylesheets and static assets

When possible (if the API surface allows) feature functionality should be encapsulated in a plugin in src/plugins.

  • core_plugins/ (e.g., dev.perfetto.CoreCommands, dev.perfetto.Notes) contain essential functionality. They cannot be disabled by users and are always active.
  • plugins/ (e.g., dev.perfetto.Sched, com.android.AndroidStartup) are optional. Users can enable/disable them via feature flags. These are organized by reverse-DNS naming (e.g., com.android.*, dev.perfetto.*, org.chromium.*).
  • This distinction is mostly historical. These days in 90% of cases things can (and should) go only inside plugins/
  • Look at /docs/contributing/ui-plugins.md as it has extra useful content for plugin authors.

Building and Running the UI

To build and serve the UI for development:

# From the repository root
ui/build    # Builds the UI.
ui/build --typecheck # Run tsc --noEmit, doesn't bundle (faster).
ui/run-dev-server    # Starts the development server with live reload.

The UI uses:

  • TypeScript for type safety
  • Mithril as the UI framework
  • Rollup for bundling
  • pnpm for package management
  • ESLint for linting (based on Google style)
  • Playwright for integration tests

Type checking when a build is already running

Each build claims a lockfile as it works, unless --no-build option is passed. If you try to run a build but encounter a failure due to one of those lockfiles being present, you can try just checking types using the following command without interfering with the current build.

ui/build --typecheck --no-build

Plugin Architecture

Plugins are the primary extension mechanism for the UI. They follow this structure:

import {PerfettoPlugin} from '../../public/plugin';
import {Trace} from '../../public/trace';
import {App} from '../../public/app';

export default class MyPlugin implements PerfettoPlugin {
  // Unique reverse-DNS identifier
  static readonly id = 'com.example.MyPlugin';

  // Optional: Human-readable description
  static readonly description = 'Does something useful';

  // Optional: Declare dependencies on other plugins
  static readonly dependencies = [OtherPlugin];

  // Called when the plugin is activated (before trace load)
  static onActivate(app: App): void {
    // Register commands, sidebar items, pages that don't need a trace
  }

  // Called when a trace is loaded
  async onTraceLoad(trace: Trace): Promise<void> {
    // Register tracks, tabs, commands that need trace data
    // Query the trace processor, add tracks to the workspace
  }
}

Plugin Lifecycle:

  1. onActivate() - Called when the plugin is enabled, before any trace is loaded. Use for registering global commands, pages, and sidebar items.
  2. onTraceLoad() - Called when a trace is loaded. Use for registering tracks, tabs, and commands that depend on trace data.
  3. trace.onTraceReady event - Fired after all plugins have finished onTraceLoad(). Use for automations that need all tracks to be available.

Key APIs available to plugins:

  • trace.engine - Run SQL queries against TraceProcessor
  • trace.tracks - Register and find tracks
  • trace.selection - Manage selection state
  • trace.commands - Register commands
  • trace.tabs - Register tabs in the details panel
  • trace.timeline - Access timeline state
  • trace.workspace - Manage the track tree structure

Mithril Patterns and Best Practices

The UI uses Mithril.js. Follow these patterns:

Component Structure:

import m from 'mithril';

interface MyComponentAttrs {
  readonly value: string;
  readonly onChange: (newValue: string) => void;
}

export class MyComponent implements m.ClassComponent<MyComponentAttrs> {
  // Local state
  private expanded = false;

  view({attrs}: m.CVnode<MyComponentAttrs>): m.Children {
    return m('.my-component',
      m(Button, {label: attrs.value, onclick: () => this.expanded = !this.expanded}),
      this.expanded && m('.details', 'Expanded content'),
    );
  }
}

Mithril Rules:

  • No need to callm.redraw() most of the times. We automatically schedules redraws: (1) in Mithril's DOM event handlers; (2) after trace processor queries complete. But NOT after manually registered JS event handlers.
  • Use constructor for initialization if no DOM access is needed, or onCreate if DOM is needed.
  • Prefer using the existing widget library (ui/src/widgets/) over creating new components.
  • Use readonly for attrs properties to prevent accidental mutation. We like things to be immutable.

Conditional Rendering with State Preservation: Use the Gate component when you need to conditionally show/hide content while preserving component state:

import {Gate} from '../base/mithril_utils';

m(Gate, {open: this.isVisible}, m(ExpensiveComponent));

Declarative Data Loading (AsyncMemo)

UI components in Mithril render synchronously, but often depend on asynchronous data (e.g. SQL queries). Never hand-roll data fetching in lifecycle hooks (oninit/onupdate) using manual loading booleans, sequence counters (fetchSeq), or prevId tracking. Likewise, avoid initiating data fetching directly inside DOM event handlers (onclick, onkeydown, etc.). Loading should be a declarative product of state, which could be triggered from many different places (e.g. keyboard shortcuts, external selection, deep links). Update the state in the event handler and let the redraw mechanism handle data loading automatically.

Using AsyncMemo

AsyncMemo<T> provides declarative, keyed async fetching directly inside view():

import m from 'mithril';
import {AsyncMemo, TASK_CANCELLED} from '../base/async_memo';

export function MyComponent(): m.Component<MyComponentAttrs> {
  // 1. Instantiate once per component (closure or class field), NOT inside view(), as this is where the cache is stored.
  const dataMemo = new AsyncMemo<MyData>();

  return {
    view({attrs}) {
      // 2. Declare dependencies via `key`. compute() runs automatically when key changes.
      const result = dataMemo.use({
        key: {traceId: attrs.trace.id, filter: attrs.filter},
        compute: async (signal) => {
          const summary = await querySummary(attrs.trace.engine, attrs.filter);

          // Optional: check cancellation to bail out early if superseded or disposed.
          if (signal.isCancelled) return TASK_CANCELLED;

          const details = await queryDetails(attrs.trace.engine, attrs.filter);
          return {summary, details};
        },
        // Optional: show stale data while fetching when only certain keys change
        retainOn: ['filter'],
      });

      if (result.isPending) {
        return m(Spinner);
      }

      return m('.my-component', renderData(result.data));
    },
    onremove() {
      // 3. Optional: dispose the memo on unmount to cancel pending tasks or clean up resources early
      dataMemo.dispose();
    },
  };
}

Key behaviors of AsyncMemo:

  • Keys are compared by value (structural equality): The key can be any JSON-compatible structure (primitives, objects, arrays, bigints). Keys are serialized via stringifyJsonWithBigints and compared by value rather than object reference, so passing an object literal created during render (e.g. key: {traceId: attrs.trace.id, filter: attrs.filter}) will only trigger a re-fetch if its contents actually change. When the key changes, any pending task is superseded (“latest wins”).
  • Automatic Redraw: AsyncMemo automatically calls m.redraw() when compute finishes. Never call m.redraw() manually inside compute.
  • Concurrency Control: Tasks are executed serially via an internal AtomicTaskQueue, preventing interleaved queries against shared resources (like temporary tables). Multiple memos can share an AtomicTaskQueue if needed.
  • Cancellation: compute receives a CancellationSignal. Long tasks can check signal.isCancelled and return TASK_CANCELLED to avoid caching stale results.
  • Stale Transitions (retainOn): If you specify retainOn: ['pagination'], changing pagination will continue returning the previous result.data with result.isPending = true, avoiding visual flicker while fetching.
  • Automatic Resource Disposal (AsyncDisposable): If the returned value is disposable (implements AsyncDisposable), it will automatically be disposed when no longer required—specifically, when a new value replaces it after a key change, when memo.invalidate() is called, or when the memo itself is disposed in onremove(). Disposal is coordinated through the task queue so it stays synchronized with in-flight work.
  • compute functions should be side-effect free: A compute function should only derive data or manage cached SQL structures. Do not mutate external state inside compute. The only allowed side effects are temporary SQL entities (tables, views, indexes)—and these must be dropped in the returned disposable (AsyncDisposable) so they are cleaned up automatically when evicted or invalidated.

Multi-Step Operations & AtomicTaskQueue

When an operation requires more than one asynchronous step (e.g., creating temporary tables/views, dropping old tables, and then querying them), standard async/await code easily suffers from race conditions. If inputs change while task A is halfway through, task B might start and drop or overwrite temporary tables that task A is still querying.

AtomicTaskQueue runs one task at a time to completion then starts the next task. Sharing a single AtomicTaskQueue across multiple AsyncMemo instances guarantees that their multi-step queries never interleave.

Chaining AsyncMemo Instances (Multi-Tier Caching)

When some state changes infrequently (e.g., creating temporary mipmap tables or preparing views) while other derived state changes frequently (e.g., timeline pan/zoom bounds, pagination, or filters), chain two AsyncMemo instances together:

import {AsyncMemo, AtomicTaskQueue} from '../base/async_memo';

class MyTrack {
  // Share an AtomicTaskQueue so table setup and table querying never race
  private readonly queue = new AtomicTaskQueue();
  private readonly tableSlot = new AsyncMemo<MipmapTables>(this.queue);
  private readonly dataSlot = new AsyncMemo<Data>(this.queue);

  render(ctx: TrackRenderContext) {
    // 1. Slow/infrequent step: create temporary tables (only re-runs if track config changes)
    const tableResult = this.tableSlot.use({
      key: {trackId: this.config.trackId},
      compute: () => this.createMipmapTables(),
    });

    // If the dependent table hasn't been created yet, return a loading spinner
    // (here we just return early / undefined for brevity).
    if (tableResult.data === undefined) return;

    // 2. Fast/frequent step: query tables for visible bounds
    const dataResult = this.dataSlot.use({
      key: {
        tableName: tableResult.data.tableName,
        start: ctx.bounds.start,
        end: ctx.bounds.end,
        resolution: ctx.bounds.resolution,
      },
      compute: async (signal) => {
        return this.fetchData(tableResult.data.tableName, ctx.bounds, signal);
      },
      retainOn: ['start', 'end', 'resolution'],
    });

    if (dataResult.data === undefined) return;
    this.renderData(ctx, dataResult.data);
  }

  private async fetchData(
    tableName: string,
    bounds: Bounds,
    signal: CancellationSignal,
  ): Promise<Data | typeof TASK_CANCELLED> {
    // Multi-step query: query summary stats first, then detail slices
    const summary = await this.engine.query(`SELECT ... FROM ${tableName} ...`);
    if (signal.isCancelled) return TASK_CANCELLED;

    const details = await this.engine.query(`SELECT ... FROM ${tableName} ...`);
    if (signal.isCancelled) return TASK_CANCELLED;

    return {summary, details};
  }

  dispose() {
    this.tableSlot.dispose();
    this.dataSlot.dispose();
  }
}

Key takeaways:

  • Multi-tier caching: When the user pans or zooms, only dataSlot re-runs; tableSlot remains cached and does not re-create tables.
  • Guaranteed non-interleaving: Notice that fetchData runs multiple queries across asynchronous await points. Because tableSlot and dataSlot share the same AtomicTaskQueue, it is impossible for createMipmapTables (or any other task on this queue) to run in between the two queries in fetchData. The queue ensures all tasks run to completion atomically and serially.

Widget Library

The ui/src/widgets/ directory contains reusable components. Always check here before creating new UI elements:

  • Button, ButtonBar, ButtonGroup - Various button styles
  • PopupMenu, Menu, MenuItem, MenuDivider - Dropdown menus
  • Popup - Floating popup containers
  • Modal - Modal dialogs
  • TextInput, Select, Checkbox, Switch - Form controls
  • Tree - Tree view component
  • DataGrid - Tabular data grid component
  • Tabs - Tabbed interface
  • Spinner - Loading indicator
  • EmptyState - Empty state placeholder

Using Widgets:

import {Button, ButtonVariant} from '../widgets/button';
import {Popup} from '../widgets/popup';

m(Button, {
  label: 'Click me',
  icon: 'search',
  variant: ButtonVariant.Filled,
  onclick: () => { /* handle click */ },
});

TypeScript Code Style

Follow these guidelines for TypeScript code:

  • Avoid any as much as you can: Use @typescript-eslint/no-explicit-any rule if you really need it. In most cases it's enough to use unknown and type guards instead.
  • Unused variables: Prefix with underscore (_unused) to satisfy @typescript-eslint/no-unused-vars.
  • Strict boolean expressions: Don't use numbers or strings in boolean contexts implicitly.
  • Readonly by default: Use readonly for interface properties and function parameters.
  • Use existing utilities: Check ui/src/base/ for utilities before writing your own:
    • time.ts, duration.ts - Time handling
    • logging.ts - assertTrue(), assertExists(), assertFalse()
    • disposable_stack.ts - Resource cleanup
    • deferred.ts - Promise utilities
    • string_utils.ts - String manipulation
    • array_utils.ts - Array helpers

Working with TraceProcessor

Plugins query data using SQL through the TraceProcessor engine:

async onTraceLoad(trace: Trace): Promise<void> {
  const result = await trace.engine.query(`
    SELECT ts, dur, name
    FROM slice
    WHERE name LIKE '%mySlice%'
    LIMIT 100
  `);

  // Use typed iteration
  const iter = result.iter({
    ts: LONG,      // bigint
    dur: LONG,     // bigint
    name: STR,     // string
  });

  for (; iter.valid(); iter.next()) {
    console.log(iter.ts, iter.dur, iter.name);
  }
}

Track creation

Rarely you need to create a new Track from scratch. In most cases you can use higher level components in ui/src/components/tracks/, especially DatasetSliceTrack (examples in /docs/contributing/ui-plugins.md). Look at those examples first and keep creating a track via trace.tracks.registerTrack as a last-resort.

CSS/SCSS Conventions

Stylesheets live in ui/src/assets/ and component-specific .scss files alongside components.

  • Use the pf- prefix for all CSS classes (Perfetto namespace)
  • Follow BEM-like naming: .pf-component, .pf-component__element, .pf-component--modifier
  • Use CSS custom properties (variables) defined in theme_provider.scss for colors
  • Support both light and dark themes using semantic color variables

Common Pitfalls to Avoid

  1. Don't create new widgets without checking existing ones - The widget library is comprehensive.
  2. Try to use the Trace object as much as possible - Plumb the Trace object through the hierarchy wherever needed.
  3. Don't fetch async data in oninit/onupdate or DOM events (onclick, onkeydown) - Loading should be a declarative product of state; update state and let AsyncMemo in view() handle fetching via the redraw mechanism.
  4. Disposing AsyncMemo instances in onremove() is optional - You can call .dispose() to cancel pending tasks and clean up cached disposable resources eagerly when the component unmounts.
  5. Never instantiate AsyncMemo inside view() - It must be created in the component setup closure or class constructor/field so its cache persists across render cycles.

Code Review Pet Peeves and Style Preferences

The following patterns are consistently enforced during code review. Adhering to these will significantly speed up the review process.

See also: UI Review Antipatterns — a deeper, categorized catalogue of antipatterns mined from real maintainer review feedback on contributor PRs (Mithril/rendering, state, layering, plugin/API design, widgets, CSS, types, error handling, performance, PR scope). Consult it when authoring or reviewing UI changes to pre-empt recurring mistakes.

TypeScript/JavaScript Style

Prefer undefined over null:

// Bad
function getValue(): string | null { return null; }

// Good
function getValue(): string | undefined { return undefined; }

Use readonly T[] for arrays that shouldn't be modified:

// Bad
function process(items: string[]): void { ... }

// Good
function process(items: readonly string[]): void { ... }

Use classNames() utility for building CSS class strings:

import {classNames} from '../base/classnames';

// Bad
const cls = 'pf-row' + (isSelected ? ' pf-row--selected' : '') + (isDisabled ? ' pf-row--disabled' : '');

// Good
const cls = classNames('pf-row', isSelected && 'pf-row--selected', isDisabled && 'pf-row--disabled');

Use assertUnreachable() in switch default cases:

import {assertUnreachable} from '../base/logging';

switch (value) {
  case 'a': return handleA();
  case 'b': return handleB();
  default:
    assertUnreachable(value); // TypeScript will error if cases aren't exhaustive
}

Variables should be camelCase:

// Bad
const trace_processor_id = 123;

// Good
const traceProcessorId = 123;

CSS/SCSS Style

Never use inline styles - use stylesheets:

// Bad
m('div', {style: {color: 'red', padding: '10px'}}, 'content')

// Good
m('.pf-my-component', 'content') // with styles in .scss file

All CSS classes must have the pf- prefix:

// Bad
.my-component { ... }
.row { ... }

// Good
.pf-my-component { ... }
.pf-my-component__row { ... }

Never hard-code colors - use theme variables:

// Bad
.pf-my-component {
  color: #333;
  background: white;
}

// Good
.pf-my-component {
  color: var(--pf-color-foreground);
  background: var(--pf-color-background);
}

Mithril-Specific Rules

Don't use oncreate/lifecycle hooks for things that can be done in view():

// Bad - splitting code across lifecycle methods hurts readability.
oncreate() {
  this.computedValue = inexpensiveComputation();
}

// Good - compute in view. If expensive initialize in the constructor.
view() {
  const computedValue = inexpensiveComputation();
  return m('div', computedValue);
}

Widget Usage

Use the Anchor widget for links:

import {Anchor} from '../widgets/anchor';
import {Icons} from '../widgets/icons';

// Bad
m('a', {href: 'https://example.com', target: '_blank'}, 'Link')

// Good
m(Anchor, {href: 'https://example.com', icon: Icons.ExternalLink}, 'Link')

Naming Conventions

Settings/flags should use reverse-DNS format:

// Bad
const settingId = 'trackHeightMinPx';

// Good
const settingId = 'dev.perfetto.TrackHeightMinPx';

Command IDs should be descriptive but omit redundant plugin name:

// Bad (if plugin is com.android.OrganizeNestedTracks)
const commandId = 'com.android.OrganizeNestedTracks#organizeNestedTracks';

// Good
const commandId = 'com.android.OrganizeNestedTracks';

Copyright years should be current when creating new files: But don't touch years when editing existing files.

// Bad (if current year is 2025)
// Copyright (C) 2024 The Android Open Source Project

// Good
// Copyright (C) 2025 The Android Open Source Project

Testing

Use Zod for parsing objects of unknown types:

import {z} from 'zod';

// Bad - unsafe type assertion
const config = JSON.parse(data) as MyConfig;

// Good - validated parsing
const ConfigSchema = z.object({
  name: z.string(),
  value: z.number(),
});
const config = ConfigSchema.parse(JSON.parse(data));

UI Unit Tests

Unit tests are run with:

$ui/run-unittests

TypeScript unit tests follow the pattern *_unittest.ts and use Jest.

UI Integration Tests

Integration tests use Playwright:

ui/run-integrationtests