perf(desktop): highlight Files changed off the main thread

Merged
DA
2d ago
+338-6

Summary

This is the second perf PR for the lagging Files changed tab, after #3733. Profiling showed that on code-heavy pull requests the main cost was Shiki highlighting on the window's thread. On #2908 (122 files) it took about 1.3 s of a 4 s scroll, with single frames up to ~1 s while a large file was highlighted. Pierre also drops a file's highlighted result when the file scrolls out of view, so scrolling back highlights it again.

This PR moves highlighting into Pierre's own worker pool (@pierre/diffs/worker):

  • Build (apps/desktop/scripts/build.mjs):
    • Pierre's worker is bundled whole into one classic script (IIFE). Its shiki/wasm import is replaced with nothing, because the panel's policy admits no WebAssembly and the bundle asks for Shiki's JavaScript engine.
    • The script's text is handed to the lazy diff-view bundle as DIFF_HIGHLIGHT_WORKER, the same way PACKAGED_POSTHOG_PROJECT_API_KEY is.
    • Nothing lands in the main panel bundle.
  • Start (@luke/panel/diff-view/highlight-pool's startHighlighting):
    • It takes a door that starts one worker and starts a pool of 2.
    • With no door, the list highlights on the window's thread. That is the case when the build carried no worker, as in every test.
    • When the door throws, or a worker fails while it loads, Pierre marks the pool as not working and highlights on the window's thread, so the list still gets its colours.
    • Worker errors are preventDefaulted rather than reported as the window's own errors.
  • Bundle (diff-view/index.ts):
    • The first list drawn starts the pool, and the pool lives as long as the window. Each worker is started from a blob: URL of that text, which the panel's CSP already admits (worker-src blob:).
    • CodeView receives the pool.
    • When the look changes, the new look is handed to the pool's workers.
    • Each patch is parsed under a cache key of its own (a counter, readPatch). The pool's AST cache therefore gives a file scrolled back into view its colours again, and a changed patch never gets the colours of the previous one.
  • Classic, not module, worker: it's one self-contained IIFE, so a module worker would gain nothing. It starts under Electron from the app's own file:// origin.
  • The engine is unchanged: the panel already highlighted with Shiki's JS regex engine (preferredHighlighter: "shiki-js"), and the worker uses the same one.

Before / after

Chromium harness — the same throwaway harness as #3733, with the real AgentPullRequestTab and real GitHub file answers, in headless Chromium at 1280×800. Scroll: 60 px per frame for 4 s. Keys: 10 J presses. Each figure is the median of 3. "Before" is main with #3733.

beforeafter
#2908 (122 files) long tasks during 4 s scroll674 ms54 ms
#2908 longest scroll frame567 ms83 ms
#2908 frames drawn in the 4 s scroll191221
#2908 long tasks over 10 J presses369 ms52 ms
#2908 long tasks on load496 ms369 ms
#2855 (300 files) long tasks during scroll499 ms0 ms
#2855 longest scroll frame250 ms33 ms
#2855 long tasks on load919 ms537 ms
#2855 long tasks over 10 J presses143 ms67 ms
#3466 (images + md) long tasks during scroll58 ms0 ms
#3466 long tasks on load433 ms247 ms
First coloured line after mount (#2908 / #2855)863 / 1223 ms883 / 1229 ms (unchanged)

Electron (43.3, under xvfb, loading the harness from file:// as the panel loads itself). #2908, 4 s scroll, 3 runs each:

without the workerwith the worker
Long tasks754 / 1272 / 1007 ms51 / 0 / 0 ms
Longest frame700 / 1183 / 900 ms83 / 67 / 67 ms
Frames drawn181 / 160 / 169226 / 222 / 223

Highlighting looks identical

I rendered TSX and Markdown with code fences from the same build twice, once with the worker and once without it (main thread). The two renders are pixel-identical, 0 differing bytes, in both themes.

TSX, light: main thread vs worker
Markdown with code fences, dark: main thread vs worker

Fallback

In Electron I forced the worker's script to throw as it loads. Both workers failed, and the list was still coloured: highlighting fell back to the main thread, with 98 coloured tokens, the same count as with the worker.

Electron, the worker made to fail: the list still highlights on the main thread

In the repo, highlight-pool.test.ts stands workers in at the door and checks four cases:

  • With no door, the list is in main-thread mode.
  • A worker that answers Pierre's start gives pool mode.
  • A door that throws gives main-thread mode.
  • A worker that errors as it loads gives main-thread mode.
  • A pool kept across lists is told each list's look (follow), which Devin's review found was missing.

Bundle budget

renderer/diff-view.js grows from 179,005 to 256,515 gzipped bytes (+77,510). The growth is the worker's own copy of Shiki core, the JS regex engine, jsdiff and hast, measured at about 70.7 KB gzipped on its own. That is a feature's room, so only diff-view's line in bundle-budget.json is re-recorded; the other bundles' drift on main is left as it stands. renderer/panel.js is unchanged: 943,679 against main's 943,706.

Evidence

  • tsc --noEmit for desktop and panel: clean.
  • ./scripts/oxlint.sh: 0 errors.
  • pnpm knip: clean.
  • ./scripts/repository-checks.sh: passed.
  • biome check: clean, apart from 2 warnings that are already on main.
  • vitest: the desktop project, 205 files with 2150 tests, passed; the panel project, 17 files with 77 tests, passed (4 of them new).
  • node scripts/build.mjs: passes the budget with the re-recorded line.
  • pnpm install --frozen-lockfile passes. The lockfile change is the one line for @pierre/diffs as a dependency of @luke/panel.
  • I checked by hand in Chromium and in Electron, light and dark, that 2 workers start from blob: and answer, and that the forced-failure run falls back.

Invariants

  • Checks green locally, as listed above.
  • JSON Schema goldens unchanged.
  • Behaviour change tested: highlight-pool.test.ts.
  • No new as.
  • No new Effect.run*.
  • No raw async primitives: queueMicrotask appears only in the test's stand-in worker.
  • No discarded Effect.
  • Test count +4.
  • Deps: @pierre/diffs 1.5.2 added to @luke/panel, the same pin the desktop uses.
  • PRIVACY.md unchanged.
  • No AGENTS.md change.

Notes

This is the third PR of the Files changed stack. Not addressed here: Pierre's own height reconciliation (reconcileHeights, a forced layout of ~350 ms per 4 s scroll on #2908). It is internal to Pierre, so I'll revisit it only if it is still the biggest cost.

Made with Luke


Devin Review