Skip to content

perf(desktop): hold 60fps under load — salvage three render-churn fixes + finish the selector sweep - #72388

Merged
OutThisLife merged 11 commits into
mainfrom
bb/desktop-render-salvage
Jul 27, 2026
Merged

perf(desktop): hold 60fps under load — salvage three render-churn fixes + finish the selector sweep#72388
OutThisLife merged 11 commits into
mainfrom
bb/desktop-render-salvage

Conversation

@OutThisLife

Copy link
Copy Markdown
Collaborator

Keeps the renderer at frame cap while sessions stream. Salvages three external PRs that attack the same defect classes the perf arc (#71925#72346) was built to find, and finishes the selector sweep the audit turned up on our side. External work is cherry-picked so authorship survives.

Salvaged

#69030 by @webtecnica — stop $sessionStates churn on no-op heartbeats. The ~1/s session.info heartbeat republished the whole store even when nothing changed: updateSessionState always spread a fresh object, publishSessionState always set. Every computed projection and subscriber paid for it, and the object churn starved the GC (renderer 350MB → 1.2–5GB before OOM in the report). Now the updater gets the raw previous state so a no-op returns the same reference, and both layers skip the publish on reference equality — "preserve reference identity on no-ops," enforced at the producer. One follow-up of ours: the rotation signal that moved into ensureSessionState is null-guarded (a cleared stored id is a detach, not a rotation).

#44884 by @travelreader8-stack — keep message component types stable across Thread re-renders. The messageComponents map listed the onBranchInNewChat/onCancel callbacks as memo deps, so any parent handing down fresh callback identities minted new component types — React unmounted and remounted every visible message, shiki re-highlighted, the thread visibly jumped (CDP: layout shifts 0.39+0.47 per 15s poll cycle). Callbacks now route through a render-assigned ref; only their definedness stays a dep. Ported to the post-split thread/index.tsx and extended to the two callbacks added since (onDismissError, onRestoreToMessage). The salvaged regression test fails on main and passes here (verified by revert).

#66160 by @andyylin (building on @HOYALIM's #61084) — bound the idle animation loops. backgroundThrottling: false is load-bearing for streaming, but it let three decorative loops run at display refresh forever, even fully occluded: the pet sprite rAF'd continuously for a ~5 fps sprite, roam rAF'd through idle dwell, and the persistent terminal polled an unchanged rect every frame (~1,200 callbacks/10s alone on a 120Hz Mac). Sprite wakes now follow the actual frame cadence, dwell uses timers with rAF reserved for real movement, terminal rect reads are event-invalidated, and decorative work pauses while the window is hidden/minimized/unfocused. Streaming delta flushes stay on the bounded timer.

Selector sweep (ours — same class #72163 fixed, remaining sites)

  • ModelPickerOverlay subscribed to $focusedSessionState whole (republished per delta, mounted app-wide) to read model/provider — re-running the un-memoized closed dialog ~30×/s during a stream. Now two useStoreSelector scalars.
  • ComposerStatusStack subscribed to the whole $statusItemsBySession map, which rebuilt fresh arrays and item objects on every input churn (subagent ticks, 5s polls, todos — any session), re-rendering every mounted stack per tile. The computed now stabilizes per-key references (unchanged sessions keep their arrays, unchanged maps skip the notify) and the stack reads its own session's slice via useSessionSlice.
  • use-statusbar-items still had two wholesale reads: $subagentsBySession for two counts, $sessions for one row's started_at. Both are scalar selections now.
  • SessionTabMenu subscribed to $sessions + $projectTree for values it never rendered — every tab's menu wrapper re-rendered on any list/tree churn for a menu that's almost never open. It now derives the three scalars it shows behind a keyed bail-out (same shape as the TreeGroup fix in perf(desktop): stop the whole transcript re-rendering on sash drag #72245).

Verified

  • thread-remount regression test: red with the fix reverted against main, green here
  • Full desktop suite: 3,280 passed / 3 skipped; tsc --noEmit and eslint clean on touched files
  • Salvaged suites (pet sprite, roam, persistent terminal, delta flush): 15/15

Closes #69030. Closes #44884. Closes #66160. Fixes #69016.

webtecnica and others added 11 commits July 26, 2026 20:47
…$sessionStates

The renderer OOMs every ~60s because periodic ~1/s session.info
heartbeats churn the entire $sessionStates store on every tick even
when nothing changed. Each heartbeat:

1. Called updateSessionState with the running-test updater, which
   always returned a new spread object — even when busy state hadn't
   changed — because the updater param was already a fresh spread.

2. publishSessionState then spread the full $sessionStates record
   and set it, firing every computed atom ($workingSessionIds,
   $attentionSessionIds) and their subscribers on every heartbeat.

Over 60s × ~1/s heartbeat this continuous store churn creates
millions of short-lived objects, amplifies React re-renders, and
starves the GC, sending the renderer working set from ~350 MB to
1.2-5 GB before the OOM crash.

Fix (two changes):

1. updateSessionState: pass the raw previous state (not a spread) to
   the updater so it can return the same reference on no-op. Skip the
   store write, publishSessionState, and syncSessionStateToView when
   the updater returned the same reference. The rotation signal from
   storedSessionId changes is now emitted directly from
   ensureSessionState since publishSessionState (and thus
   handleTransition) is skipped on no-op.

2. publishSessionState: guard with `prev === state` reference check
   (belt-and-suspenders for any other caller).

Fixes #69016
…nders

The component map Thread passes to the virtualizer listed the
onBranchInNewChat / onCancel callbacks as useMemo deps. Whenever a parent
re-render handed down a fresh callback identity, the memo rebuilt the map
and produced new component *types*, so React unmounted and remounted every
visible message. Async-rendered parts (shiki code blocks) collapsed and
re-expanded on each remount, making the whole thread visibly jump.

That is exactly what shipped in v0.15.1: the desktop controller passed an
inline arrow for onBranchInNewChat, and the 15s status-snapshot poll
re-rendered the controller, so threads with code blocks jumped every 15
seconds (layout-shift scores of 0.39 + 0.47 per cycle, measured via CDP).
arrow away from regressing.

Route the callbacks through a ref so the component types survive any
parent re-render; only the callbacks' definedness stays a dep, because it
gates UI (the user-message Stop button). Add a regression test that fails
on the old code by asserting message DOM nodes keep their identity when
callback props change identity.

Tested on macOS arm64 (vitest + rebuilt app, CDP layout-shift
instrumentation confirms zero shifts over multiple poll cycles).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Ho Lim <166576253+HOYALIM@users.noreply.github.com>
…State

The salvaged no-op-publish guard moved the compression-rotation signal
into ensureSessionState, where storedSessionId is string|null. A cleared
stored id is a detach, not a rotation — firing the event with a null
next id would send the route-follow effect chasing nothing (and tsc
rejects it). Guard on a real next id.
…ng token

ModelPickerOverlay subscribed to $focusedSessionState whole — a
projection of $sessionStates, republished on every message delta — to
read two fields that essentially never change (model, provider). The
overlay is mounted app-wide and unconditionally renders the un-memoized
ModelPickerDialog (closed), so the focused session's stream re-ran the
dialog's full hook body ~30x/s.

Same defect class and same fix as the statusbar (#72163): select each
scalar through useStoreSelector so unchanged values bail out.
…tatus stack

$statusItemsBySession rebuilt its whole output map — fresh arrays,
fresh item objects — on every recompute, and its inputs churn constantly
(subagent ticks, 5s background polls, todo updates, in ANY session). A
whole-map useStore in ComposerStatusStack then re-rendered every mounted
stack — one per open tile — on all of it, and the fresh item objects
defeated row memoization downstream.

Two halves, per the documented slice contract (use-session-slice.ts):

- producer: stabilize $statusItemsBySession per key — an unchanged
  session keeps its previous array and item objects, and a fully
  unchanged map keeps its previous reference so computed skips the
  notify entirely ('preserve reference identity on no-ops').
- consumer: the stack subscribes to its OWN session's slice via
  useSessionSlice instead of the whole map.
#72163 narrowed $focusedSessionState but left two whole-store reads in
the same hook paying the same price:

- $subagentsBySession: only two COUNTS are rendered, but the whole-map
  subscription re-ran the hook (rebuilding all ~9 statusbar items) on
  every subagent progress tick in any session. Select the two scalars.
- $sessions: only one row's started_at is read, but any session-list
  write (title update, poll refresh, archive) re-ran the hook. Select
  the one scalar.
…ing wholesale

SessionTabMenu subscribed to $sessions + $projectTree for values it
never rendered (the row was re-read imperatively), so every tab of every
tile re-rendered its menu wrapper on any session-list or project-tree
churn — for a context menu that is almost never open. Same class as the
TreeGroup fix (#72245): derive the three scalars the menu actually shows
(pinId, title, profile) behind a keyed bail-out, so the wrapper only
re-renders when one of them changes.
@alt-glitch alt-glitch added type/perf Performance improvement or optimization comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have labels Jul 27, 2026
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 3d6c32b

ℹ️ Info

Desktop E2E visual evidence · View test artifacts · View job

1 visual diff.

inline evidence upload failed.

Failed to upload diff-665a0833239e-onboarding-overlay-diff.png with gh image (exit code 1): Error uploading /home/runner/work/_temp/e2e-evidence/diff-665a0833239e-onboarding-overlay-diff.png: step 0 (get upload token): uploadToken not found on repo page — do you have write access to NousResearch/hermes-agent? (or, if NousResearch enforces SAML SSO, authorize at https://github.com/orgs/NousResearch/sso)

@OutThisLife
OutThisLife merged commit af1cc1c into main Jul 27, 2026
36 checks passed
@OutThisLife
OutThisLife deleted the bb/desktop-render-salvage branch July 27, 2026 02:44
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…nder-salvage

perf(desktop): hold 60fps under load — salvage three render-churn fixes + finish the selector sweep
prmartinow pushed a commit to prmartinow/hermes-agent that referenced this pull request Aug 26, 2026
…nder-salvage

perf(desktop): hold 60fps under load — salvage three render-churn fixes + finish the selector sweep
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have type/perf Performance improvement or optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Desktop renderer memory leak: render process OOM-killed (render-process-gone, exitCode -36861) roughly every 60s

6 participants