perf(desktop): highlight Files changed off the main thread
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/wasmimport 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-viewbundle asDIFF_HIGHLIGHT_WORKER, the same wayPACKAGED_POSTHOG_PROJECT_API_KEYis. - Nothing lands in the main panel bundle.
- Pierre's worker is bundled whole into one classic script (IIFE). Its
- Start (
@luke/panel/diff-view/highlight-pool'sstartHighlighting):- 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:). CodeViewreceives 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.
- The first list drawn starts the pool, and the pool lives as long as the window. Each worker is started from a
- 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.
| before | after | |
|---|---|---|
| #2908 (122 files) long tasks during 4 s scroll | 674 ms | 54 ms |
| #2908 longest scroll frame | 567 ms | 83 ms |
| #2908 frames drawn in the 4 s scroll | 191 | 221 |
| #2908 long tasks over 10 J presses | 369 ms | 52 ms |
| #2908 long tasks on load | 496 ms | 369 ms |
| #2855 (300 files) long tasks during scroll | 499 ms | 0 ms |
| #2855 longest scroll frame | 250 ms | 33 ms |
| #2855 long tasks on load | 919 ms | 537 ms |
| #2855 long tasks over 10 J presses | 143 ms | 67 ms |
| #3466 (images + md) long tasks during scroll | 58 ms | 0 ms |
| #3466 long tasks on load | 433 ms | 247 ms |
| First coloured line after mount (#2908 / #2855) | 863 / 1223 ms | 883 / 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 worker | with the worker | |
|---|---|---|
| Long tasks | 754 / 1272 / 1007 ms | 51 / 0 / 0 ms |
| Longest frame | 700 / 1183 / 900 ms | 83 / 67 / 67 ms |
| Frames drawn | 181 / 160 / 169 | 226 / 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.


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.

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 --noEmitfor 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 onmain.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-lockfilepasses. The lockfile change is the one line for@pierre/diffsas 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:
queueMicrotaskappears only in the test's stand-in worker. - No discarded Effect.
- Test count +4.
- Deps:
@pierre/diffs1.5.2 added to@luke/panel, the same pin the desktop uses. -
PRIVACY.mdunchanged. - No
AGENTS.mdchange.
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