Authors: @primiano
Status: Proposal
POC branch: dev/primiano/vite
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:
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.http, fs.watch, and child_process.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.
Replace the entire ui/build.js + rollup orchestration with Vite:
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.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:
/live_reload SSE, mtime 304s, gzip middleware (Vite dev server replaces them).tsc --watch as a JS emitter — Vite/esbuild transform .ts directly.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.manifest.json + duplicated index.html mechanism (required by the offline service worker).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.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.
scripts/build.mjs)PERFETTO_UI_*, ~/.config/perfetto/ui-dev-server.env). CLI flags always win; precedence is documented in the parser.<outDir>/ui/ for a deterministic build; recreate the layout.ui/src/gen/protos.{js,d.ts}; version header; plugin import index; stdlib docs JSON.gn gen + ninja <mod>_wasm for each module, then stage .wasm into dist/<ver>/ and .{js,d.ts} glue into ui/src/gen/.ui/src/assets/index.html into dist/<ver>/index.html verbatim.ui/src/assets/perfetto.scss → dist/<ver>/perfetto.css via the sass CLI.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.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.
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):
| Action | Time |
|---|---|
| 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 |
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.
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.base.json switched:
module: commonjs → module: esnextmoduleResolution: node → moduleResolution: bundlertsc 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.
vite-plugin-checkerIn 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.
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.
vite build --watch for everythingThis 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:
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).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.
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.
Pros: fastest possible builds. Cons:
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)..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.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.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.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.bigtrace and open_perfetto_trace smoke tests in a real browser. Plumbed through both pipelines but not visually verified post-migration.