Main-Thread Performance — Delivery Notes
Delivery summary for the renderer performance pass of 2026-09-07. There is no companion ADR — nothing was decided, four existing panels were repaired. The reader-facing half is Performance & the Main Thread, which turns the same rules into authoring advice.
The pass started as a research read of an external article on main-thread cost, and turned into a sweep of the owned packages looking for the two failure modes it describes. Both were present.
Status
Verified in a real app instance by a new smoke suite, not by eye — see Verification.
| Piece | State |
|---|---|
folder-counts.js — measure/apply split, coalesced scroll | Shipped, smoke-covered, uncommitted |
row-actions.js — read before write, coalesced scroll | Shipped, uncommitted |
runs-panel.js — coalesced render, split list/detail, appended output | Shipped, smoke-covered, uncommitted |
vertical-tabs-view.js — coalesced render | Shipped, covered by ADR-0012 suite, uncommitted |
tranquil-examples — measure-and-place added, fixed sleep removed | Shipped, lint+fmt clean, uncommitted |
| Guide — Performance & the Main Thread | Shipped, lint clean, uncommitted |
Smoke suite — smoke/suites/main-thread-perf.ts | New, passing |
capnweb missing from the test suite’s import map | Found and fixed — see below |
transform instead of top/left | Considered and rejected — see below |
The two failure modes
Layout thrashing. Reading a layout value (getBoundingClientRect, offsetWidth, clientWidth, scrollHeight) forces the browser to compute layout synchronously to answer it; writing a style
invalidates that layout. Interleaved in a loop, every read pays for a fresh layout of the document.
Uncoalesced renders. A view subscribed to a high-frequency event rebuilding its DOM once per event, when the screen can only show one frame’s worth anyway.
The fix for both was already in the tree and unused by any owned package: atom.views.updateDocument(fn) and atom.views.readDocument(fn) in src/view-registry.js — a requestAnimationFrame-scheduled queue that batches writes and reads into phases.
What shipped
Measure, then apply
folder-counts.js positioned each folder-count badge by reading its geometry and immediately
writing its style, once per open folder — so folder n’s writes invalidated the layout that folder n+1’s reads forced back. positionBadge() is now split into measureBadge() (reads only) and applyBadge() (writes only), with measureAll() gathering every badge before a single write pass.
Two redundancies fell out of the split. The tree-view rect, scrollbar width and dock-mask rect are
identical for every badge and were being re-read per badge; they now come from one measureContext() call per pass. badge.offsetWidth was read twice per badge and is now read once. processDir() also
stopped positioning as it goes, so refreshAll() does content-then-position rather than
write/read/write per folder.
row-actions.js had the same shape in miniature: currentWrap.offsetWidth was read after style.top had been written, so each call paid for two layouts instead of one. All reads now precede
all writes.
One pass per frame
Both files registered capture-phase scroll on document, unthrottled — so every scrollable
container in the workspace fired a full reposition, not just the tree-view. Both now coalesce through atom.views.readDocument.
runs-panel.js was the worst case. RunManager emits did-update once per stdout/stderr chunk, at
whatever rate the OS delivers pipe data, and each one ran a full render(): wipe and rebuild every
run row and its mousedown listener, wipe the detail pane, write up to 64 KB into a fresh <pre>.textContent, then read scrollHeight and write scrollTop. Three changes:
- The streaming subscription goes through
scheduleRender(); direct user actions (select, cancel, clear) still render synchronously. - The run list only rebuilds when a signature of run ids, states and selection changes — output
chunks don’t touch it. Durations still tick, as an in-place
textContentupdate on the existing node. - The
<pre>is kept across renders and appended to with the delta, falling back to a full replace when the output no longer starts with what’s already rendered (which is what the ring buffer truncating from the front looks like).
vertical-tabs-view.js was the same family, found only on a second sweep: render() was bound to
each item’s title-changed, and a loading browser tab emits that more than once, so a few tabs
loading together meant repeated full list rebuilds plus a forceRepaint() transform toggle each
time. Every call site now routes through scheduleRender().
Examples
measure-and-place is a new example built around the read/write split — it labels every image with
its rendered size — with the interleaved version kept in a comment for contrast. It also demonstrates
hoisting scrollY out of the loop and clearing the previous run’s labels before measuring.
search-to-bookmarks lost its await delay(1500) after clicking “More results”. The replacement
marks the results already on screen and then waits on a[data-testid="result-title-a"]:not([data-tq-seen]) — a plain selector, which matters because waitFor’s predicate form takes no arguments. It also
only asks for a second page when the first didn’t cover count, and tolerates the timeout rather
than failing the run. A pre-existing unversioned jsr:@std/dotenv import was pinned while in there.
Where everything lives
| File | Change |
|---|---|
tranquil-automations/lib/folder-counts.js | measureContext/measureBadge/applyBadge, measureAll, scheduleReposition |
tranquil-automations/lib/row-actions.js | Reads hoisted above writes, scheduleReposition |
tranquil-automations/lib/runs-panel.js | scheduleRender, renderList/rebuildList, renderDetail/buildDetail |
tranquil-automations/lib/vertical-tabs-view.js | scheduleRender, revealLastOnRender |
tranquil-examples/measure-and-place/ | New example + README |
tranquil-examples/search-to-bookmarks/ | Fixed sleep replaced; dotenv import pinned |
www-tranquil guides/automations/performance | New guide, registered after Debugging |
Gotchas worth remembering
atom.views runs writers → readers → writers-queued-during-reads. That third phase is what makes
the measure/apply split safe. Queuing an updateDocument from inside a readDocument callback lands
it in the same frame, not the next — without it the badges would trail the scroll by a frame. This
is the fact to check before using the registry for anything measure-shaped.
The theme owns the badge’s vertical centering. .tranquil-folder-count is styled in tranquil-business-dark/light as top: 50%; transform: translateY(calc(-50% + 1px)). The JS
overrides position/right/top/left and deliberately never touches transform — plan.top is
the row’s centre and the theme pulls the badge up by half its height. Anything writing style.transform from JS clobbers that.
Coalescing changes ordering, not just timing. vertical-tabs-view’s onDidAddItem did render(); scrollToBottom(); and relied on the render being synchronous so scrollHeight reflected
the new row. The reveal now rides along with the render via a flag consumed at the top of render().
forceRepaint() was left synchronous on purpose in folder-counts.js. It is the stale-composited-layer
workaround, its timing relative to the transform clear is delicate, and it was not worth moving onto
a scheduler in the same pass as everything around it changed.
Considered and rejected
transform instead of top/left for the badges. The general rule is sound — geometry writes
cost layout, transform is compositor-only — but it does not hold here. The badges are position: fixed, so they are out of flow: the write repositions one element and invalidates nothing
around it. The rule bites hardest on in-flow elements where a geometry write cascades, and the real
wins were already taken by the measure/apply split and the per-frame coalescing.
Doing it anyway would mean either duplicating the theme’s - 50% + 1px into the package’s JS
(coupling a package to a theme constant that breaks silently) or moving positioning entirely into JS
and editing both theme files — three repos, on the delicate stale-layer code, for a modest gain.
Verification
A new smoke suite, tranquil-test-suite/smoke/suites/main-thread-perf.ts, covers the two things
that are invisible to a screenshot. It passes; the other nine existing suites still pass unchanged.
Badge geometry. Asserts every badge is position: fixed, that each visible one sits on its
row’s centre line within 3px, and that none escapes the tree-view’s right edge — then scrolls the
tree and re-asserts alignment. This is what proves the measure/apply split preserved the geometry,
including the theme’s centring transform that the JS must not touch.
Runs panel. Instruments the live view — wrapping render() and subscribing to did-update —
then runs fixtures/automations/chatty.ts, which prints 2400 lines and deliberately overflows the
64 KB ring buffer. Four assertions: the burst is real (updates > 30, or the test proves nothing),
renders are bounded by frames rather than chunks (renders < updates / 2), the <pre> is reused
rather than rebuilt (outputNodeChanges <= 2), and — the one that matters most — the panel’s text
equals the run’s output buffer exactly. That last one is the real check on the appended-delta
logic: it is only correct if both the append path and the truncation fallback are right, and a bug
in either shows as duplicated or missing output while still looking like a working panel. The
fixture asserts truncation actually happened, so the fallback is covered rather than assumed.
Two pre-existing failures found
Neither is caused by this pass; both were surfaced by trying to verify it.
capnweb was missing from tranquil-test-suite/deno.json — fixed here. Tranquil resolves an
automation’s --config by walking up from the script and taking the first deno.json it finds, so
for any fixture in that repo it picks the test suite’s own — not the map Tranquil seeds into $ATOM_HOME/automations/. deno/main.ts imports ./mod.ts relatively and so resolves fine, but deno/rpc.ts imports capnweb as a bare specifier and did not. Every automation run inside the
test-suite project therefore died at import with “Import ‘capnweb’ not a dependency and not in
import map”. This is the same parent-deno.json shadowing trap the examples README warns about,
biting the test suite itself. Adding "capnweb": "npm:capnweb@0.9.0" fixes it; the version has to
stay in sync with lib/deno-config-seed.js.
The debugger suite fails on an unrelated assertion. With the import fixed, ADR-0025’s suite runs
properly for the first time and fails at “a breakpoint in never-executed code must stay unbound”.
Its core claim still passes — the paused frame maps back through the source map and stops on the
right TypeScript row — so this is narrow: a breakpoint in dead code is reporting as verified when it
should stay grey. Confirmed independent of this pass by running the suite with the new step disabled
(TQ_SKIP_PERF=1): identical failure, same assertion, same timing. It needs its own investigation.
Manual checks still worth doing
The suite covers geometry and streaming, not feel. Renderer package code is frozen at window load,
so reload the window first (Ctrl-Alt-Cmd-L).
- Open a project with several folders in the tree-view. Confirm the count badges sit where they did, pinned to the visible right edge, vertically centred on their row.
- Scroll the tree-view fast, horizontally and vertically. Badges should track without lag or drift.
- Drag-resize the left dock. Badges reposition and the rows re-raster (no stale-layer bleed-through).
- Hide and reveal the dock. Badges hide once they’d overflow the mask, and come back.
- Hover tree-view rows. The rename/delete buttons pin to the right edge; scroll while hovering and they follow. Confirm the count badge yields to them rather than overlapping.
- Run an automation that logs heavily (a loop with
console.log). The Runs panel should stay responsive, output should stream and stay pinned to the bottom, and the run list should not flicker. - Scroll up in that output mid-run — it should stop auto-scrolling and stay where you put it.
- Select a different run while one is streaming, then select back. The detail pane swaps cleanly and the output is complete.
- Cancel a run mid-stream; confirm the state and the Cancel control behave as before.
- Let a run exceed the 64 KB ring buffer and confirm the output replaces rather than duplicating (the truncation path, where the appended-delta optimisation falls back to a full replace).
- Open the vertical Tabs panel with several browser tabs and load pages in them. Titles and favicons should settle without the list visibly rebuilding; a newly opened tab still scrolls into view and shows its fade highlight.
- In
tranquil-examples, runmeasure-and-placeon an image-heavy page, then re-run it — labels refresh rather than stacking. Runsearch-to-bookmarksand confirm it still pages results.
Follow-ups
- The find-in-page rule is not written down yet. Virtualizing or applying
content-visibilityto any long owned panel would silently break Tranquil’s own find, which uses Electron’s nativefindInPage(ADR-0009) and only sees rendered DOM. That belongs in the theme development notes before someone reaches for windowing as a scrolling fix. - A “profile the renderer” affordance.
tranquil-debugalready has a working renderer-side CDP client, so wiring thePerformance/Tracingdomains into a command is close to free — but it should go through ADR-0022’s planned filtered CDP proxy rather than the raw port, which is unauthenticated and lists host-window targets (security item 7). tranquil-debug/lib/debug-panel.jswas checked and left alone: its twogetBoundingClientRect()reads are correctly hoisted tomousedown, outside the drag loop, andrender()is driven by pause/step/resume rather than a stream.