UI build migration to Vite

Authors: @primiano

Status: Proposal

POC branch: dev/primiano/vite

Problem

The Perfetto UI build is driven by a hand-rolled ui/build.js (1177 LoC) that orchestrates rollup, tsc, sass, pbjs/pbts, ninja, and a custom HTTP server.

Pain points:

  1. Slow incremental dev rebuilds. Editing ui/src/frontend/sidebar.ts triggers a tsc recompile (incremental, OK) followed by a rollup re-bundle of the whole frontend, ~3–5s per cycle once warm.
  2. A lot of bespoke code. Live-reload SSE, port handling, asset copying, watch debouncing, build-lock, signal handling, all hand-rolled on top of node http, fs.watch, and child_process.

Opportunities and risks

Opportunities:

  • Move over to industry standard. Vite seems to have caught a lot of traction lately. It solves the problems we care about and has a large ecosystem.

  • The speed up on dev cycle is incredible, it retains tsc debug output for type-related issues (altough does it asynchronously).

  • More importantly works better with AI agents because there is no need to “wait for build”, as the synchronization as transpilation happens on-demand when requesting a module from the HTTP server.

  • It improves massively the dev workflow experience, as it doesn't require any bundling at all for dev.

Risks:

  • There is a lot of complexity behind Vite, as it uses a miture of rollup bundles for production but for dev server, it pushes ES modules down to the browser and transpiles them on demand. Although this complexity is supposed to be dealt with by Vite.

  • Realistically we are going to spend probably a few weeks shaking bugs here and there that will unavoidably come up.

Proposal

Replace the entire ui/build.js + rollup orchestration with Vite:

  • Production builds (ui/build): one vite.build() per IIFE bundle. Same output layout as before; same manifest.json + duplicated index.html post-build steps. Still bundles with rollup. No behavioral change at the user/serving layer.
  • Dev mode (ui/run-dev-server): use Vite's dev server for the frontend (native ES modules, esbuild per-file transforms, browser refresh in ~2s), and vite.build() for the worker / SW / chrome extension bundles (one-shot for the rarely-edited ones, watch:{} for the small ones we sometimes touch).

Hand-rolled bits we retire entirely:

  • Custom HTTP server, /live_reload SSE, mtime 304s, gzip middleware (Vite dev server replaces them).
  • tsc --watch as a JS emitter — Vite/esbuild transform .ts directly.
  • Several rollup plugins (re, sourcemaps2, our own embedMinimalSourceMap).

We keep:

  • pbjs/pbts invocations (with one option flip: -w es6 so the dev server can serve the generated protos.js as a native module).
  • tools/gen_ui_imports, tools/write_version_header.py, tools/gen_stdlib_docs_json.py — invoked by the new orchestrator.
  • tools/gn + tools/ninja for wasm. Output staging unchanged.
  • The manifest.json + duplicated index.html mechanism (required by the offline service worker).
  • The single source-of-truth production ui/src/assets/index.html — a tiny middleware in dev rewrites one line of it on the wire to load the frontend as a module.

Design

Repository layout

ui/
├── build                       # bash wrapper → scripts/build.mjs
├── run-dev-server              # bash wrapper → scripts/dev.mjs
├── scripts/
   ├── common.mjs              # layout, exec helpers, env-file loading
   ├── codegen.mjs             # protos, version, plugin index, stdlib docs
   ├── build_wasm.mjs          # gn + ninja + stage outputs
   ├── assets.mjs              # static asset + chrome-ext copy rules
   ├── postbuild.mjs           # SW manifest.json + root index.html
   ├── build.mjs               # production orchestrator
   └── dev.mjs                 # dev orchestrator
└── vite/
    ├── config.mjs              # InlineConfig factory used by both modes
    └── plugins.mjs             # custom Vite plugins

ui/src/gen/ is now a real directory (no longer a symlink) and receives all generated files (codegen + Emscripten glue) directly. This sidesteps a class of pnpm symlink interactions with @rollup/plugin-commonjs that broke ajv resolution under preserveSymlinks: true.

Production pipeline (scripts/build.mjs)

  1. Parse args + env (PERFETTO_UI_*, ~/.config/perfetto/ui-dev-server.env). CLI flags always win; precedence is documented in the parser.
  2. Wipe <outDir>/ui/ for a deterministic build; recreate the layout.
  3. Codegen: pbjs+pbts → ui/src/gen/protos.{js,d.ts}; version header; plugin import index; stdlib docs JSON.
  4. Wasm: optional gn gen + ninja <mod>_wasm for each module, then stage .wasm into dist/<ver>/ and .{js,d.ts} glue into ui/src/gen/.
  5. Copy fonts, pngs, catapult, chrome extension files; copy ui/src/assets/index.html into dist/<ver>/index.html verbatim.
  6. Compile ui/src/assets/perfetto.scssdist/<ver>/perfetto.css via the sass CLI.
  7. One vite.build() call per bundle — sequentially, sharing the output dir via emptyOutDir: false. Output is forced to <name>_bundle.js (no hashing) so the existing service-worker subresource-integrity scheme keeps working.
  8. Postbuild: walk dist/<ver>/, sha256 each non-.map/non-index.html file, write manifest.json. Duplicate dist/<ver>/index.html to dist/index.html with data-perfetto_version patched to a single- channel JSON map ({"stable":"<ver>"}) and optional <title> override.

The output tree is byte-equivalent to what the old rollup pipeline produced (modulo bundle minifier output differences); the service worker needs no changes.

Dev pipeline (scripts/dev.mjs)

ui/run-dev-server
  ├── codegen              (once)
  ├── stage wasm           (once; gn+ninja unless --no-wasm)
  ├── copy static assets   (once)
  ├── sass --watch          dist/<ver>/perfetto.css
  ├── vite.build  one-shot  traceconv, chrome_extension
  ├── vite.build  watch:{}  engine, service_worker
  └── vite.createServer     http://<host>:<port>/
        ├── publicDir = dist/   (serves /v<ver>/*, /service_worker.js,
        │                        /v<ver>/*.wasm, /v<ver>/perfetto.css)
        ├── middleware: read ui/src/assets/index.html and serve
        │   a transformed copy at / (see "HTML transform" below)
        ├── middleware: /live_reload no-op SSE stub
        ├── middleware: /test/** passthrough (Playwright fixtures)
        └── plugin: vite-plugin-checker (project-wide tsc --noEmit
            in a worker; errors land in terminal + browser overlay)

When a worker bundle changes on disk a Vite ws.send({type:'full-reload'}) is pushed so the browser refreshes automatically.

Edit-to-refresh budget on a warm cache (Linux, Node 20):

ActionTime
Cold start (one-shot bundles + dev)~5–6 s
Edit frontend .ts, hit reload~2 s
Reload, no edits~1.4 s
Edit engine/index.ts, watcher signals~0.5 s
Reload after engine rebuild~2 s

HTML transform

The production bootstrap in ui/src/assets/index.html ends with roughly:

const script = document.createElement('script');
script.async = true;
script.src = version + '/frontend_bundle.js';
script.onerror = () => errHandler(...);
document.head.append(script);

In dev, the dev server's middleware reads this same file and rewrites just that line:

script.type = 'module';
script.src = '/src/frontend/index.ts';

The error handler, the timeout, the CSS/font preloads, the data-perfetto_version channel map — all run unchanged. The middleware also injects a single inline script in <head> setting window.__PERFETTO_ASSET_ROOT__ = '/v<ver>/' so that assetSrc() (which historically derived the asset root from document.currentScript.src) still works when the frontend is loaded as a module (where document.currentScript is null). The production HTML is not modified on disk.

The only source-side change required is in ui/src/base/http_utils.ts::getServingRoot() — five lines that consult the global before falling back to the existing currentScript-based logic.

Custom Vite plugins (ui/vite/plugins.mjs)

  • pluginProtoFixup (build-only safety net): replaces eval(...moduleName); with undefined; in any .js we transform, and the process.env.NODE_ENV references that immer expects. This used to be rollup-plugin-re; same intent, more focused.
  • pluginTraceProcessor32Alias: when the 32-bit trace_processor glue exists in ui/src/gen/, redirect ./trace_processor_32_stub imports to it; otherwise leave the stub in place (which throws at runtime if the user‘s browser doesn’t support memory64).
  • pluginLezerGrammarAlias: import {parser} from './foo.grammar'; is rewritten to ./foo.grammar.js. Necessary because Vite resolves ./foo.grammar to the literal text-format grammar file, then chokes parsing it as JS.
  • pluginEmscriptenGlueToEsm (dev-server only): the MODULARIZE=1 Emscripten glue ends with a CommonJS/AMD export trailer (module.exports = X). In vite build the rollup commonjs plugin handles it; in dev-serve there is no such plugin in the hot path, so we append export default <name>_wasm; to the served file. The trailing CJS/AMD branch becomes dead code.

tsconfig change

tsconfig.base.json switched:

  • module: commonjsmodule: esnext
  • moduleResolution: nodemoduleResolution: bundler

tsc is no longer used for emit (Vite/esbuild do the transforms); these settings only affect type-check resolution, which is now aligned with what the bundlers actually do. Two minor source fixups in com.google.PerfettoMcp/{tracetools,uitools}.ts add explicit .js suffixes to the MCP SDK imports as the bundler resolver requires.

Why vite-plugin-checker

In dev mode Vite only transforms files that the browser actually imports. A type error in an unreached file (a disabled plugin, a new file not yet wired up) would silently sit there. The plugin runs tsc --noEmit over the whole project in a worker, surfaces errors both in the terminal and as a click-through browser overlay. Verified end-to-end by adding a synthetic _test_checker_error.ts containing const x: number = "string"; — the dev server logged the TS2322 even though no file imported it.

Alternatives considered

A. Stay on rollup, retrofit watch-mode

Pros: smallest change. Cons: doesn't address the “edit a frontend file → ~5s rebuild” pain. Modern alternatives (Vite, rspack, esbuild) were designed exactly to fix this; sticking with rollup leaves us with the slow path forever.

B. vite build --watch for everything

This was the original attempt. It failed: editing the frontend .ts triggered [commonjs] Cannot read properties of undefined (reading 'resolved') from @rollup/plugin-commonjs@28's rebuild-cache path on the second build, leaving the watcher wedged. We tried two escalations:

  1. Spawn vite build --watch as a subprocess per bundle and respawn on that specific error code (worked but every frontend edit took ~21 s for a full cold rebuild — unusable).
  2. Patch meta.commonjs.resolved access with a ?. guard inline in node_modules/.vite/.../dep-XXXX.js and @rollup/plugin-commonjs, reapplied via a postinstall hook (worked, no more crashes, but the rebuild stayed at ~15 s because rollup was still re-bundling the whole frontend on every edit).

Both approaches were rejected for the chosen design (dev-server + selective watch). The patches and the per-bundle subprocess scaffolding have been removed.

C. Webpack / rspack / turbopack

Pros: also have fast dev modes. Cons: bigger config surfaces, less idiomatic for SPA + classic-script workers. Also the ecosystem seems to be shifting in favor of Vite.

D. ESBuild directly (no bundler framework on top)

Pros: fastest possible builds. Cons:

  • we‘d be writing our own dev server + asset pipeline + CSS extractor + dep-pre-bundling all over again. That’s exactly what we left behind.
  • Rollup is still a better bundler (which vite uses in full buildS).

Implementation notes / non-goals

  • Workers are still classic IIFE scripts. frontend does new Worker(assetSrc('engine_bundle.js')) (no {type: 'module'}). The engine bundle is built as IIFE in both prod and dev. Switching to module workers would simplify some things but require source changes and has its own caveats (some Emscripten module-worker bugs in the past).
  • Source maps in dev are off by default. Vite serves the original .ts source unmodified to the browser, so devtools can map line/col natively without us emitting .map files. This kept dev rebuild times tight; can be re-enabled per-bundle if desired.
  • Production source maps are still emitted per Vite's defaults but the old embedMinimalSourceMap mechanism (a compact map registry on self.__SOURCEMAPS[<bundleName>], consumed by ui/src/base/source_map_utils.ts for stack-trace decoration on error reports) was not ported. The runtime tolerates the registry being absent. Re-adding it as a Vite generateBundle plugin is left as a TODO that should be addressed.
  • Worker rebuild auto-reload uses Vite‘s HMR websocket (server.ws.send({type:'full-reload'})) rather than the old SSE channel. The frontend’s core/live_reload.ts was deleted along with its callers; the dev server still serves a no-op SSE stub at /live_reload for any out-of-tree consumer that pings it.

Open questions / follow-ups

  • isolatedModules: true in tsconfig.base.json. Would surface the handful of mixed value/type re-exports left in the codebase (histogram.ts, track_helper.ts). Currently both prod and dev build clean without it; it's a tightening choice, not a correctness one.
  • Per-bundle source-map strategy. Re-enable for dev only? Match prod sizes? Bring back the embedded minimal-map registry?
  • bigtrace and open_perfetto_trace smoke tests in a real browser. Plumbed through both pipelines but not visually verified post-migration.