Docs

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.

PieceState
folder-counts.js — measure/apply split, coalesced scrollShipped, smoke-covered, uncommitted
row-actions.js — read before write, coalesced scrollShipped, uncommitted
runs-panel.js — coalesced render, split list/detail, appended outputShipped, smoke-covered, uncommitted
vertical-tabs-view.js — coalesced renderShipped, covered by ADR-0012 suite, uncommitted
tranquil-examplesmeasure-and-place added, fixed sleep removedShipped, lint+fmt clean, uncommitted
Guide — Performance & the Main ThreadShipped, lint clean, uncommitted
Smoke suite — smoke/suites/main-thread-perf.tsNew, passing
capnweb missing from the test suite’s import mapFound and fixed — see below
transform instead of top/leftConsidered 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 textContent update 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

FileChange
tranquil-automations/lib/folder-counts.jsmeasureContext/measureBadge/applyBadge, measureAll, scheduleReposition
tranquil-automations/lib/row-actions.jsReads hoisted above writes, scheduleReposition
tranquil-automations/lib/runs-panel.jsscheduleRender, renderList/rebuildList, renderDetail/buildDetail
tranquil-automations/lib/vertical-tabs-view.jsscheduleRender, revealLastOnRender
tranquil-examples/measure-and-place/New example + README
tranquil-examples/search-to-bookmarks/Fixed sleep replaced; dotenv import pinned
www-tranquil guides/automations/performanceNew 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 transformplan.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).

  1. 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.
  2. Scroll the tree-view fast, horizontally and vertically. Badges should track without lag or drift.
  3. Drag-resize the left dock. Badges reposition and the rows re-raster (no stale-layer bleed-through).
  4. Hide and reveal the dock. Badges hide once they’d overflow the mask, and come back.
  5. 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.
  6. 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.
  7. Scroll up in that output mid-run — it should stop auto-scrolling and stay where you put it.
  8. Select a different run while one is streaming, then select back. The detail pane swaps cleanly and the output is complete.
  9. Cancel a run mid-stream; confirm the state and the Cancel control behave as before.
  10. 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).
  11. 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.
  12. In tranquil-examples, run measure-and-place on an image-heavy page, then re-run it — labels refresh rather than stacking. Run search-to-bookmarks and confirm it still pages results.

Follow-ups

  • The find-in-page rule is not written down yet. Virtualizing or applying content-visibility to any long owned panel would silently break Tranquil’s own find, which uses Electron’s native findInPage (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-debug already has a working renderer-side CDP client, so wiring the Performance/Tracing domains 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.js was checked and left alone: its two getBoundingClientRect() reads are correctly hoisted to mousedown, outside the drag loop, and render() is driven by pause/step/resume rather than a stream.