perf(desktop): hold 60fps under load — salvage three render-churn fixes + finish the selector sweep - #72388
Merged
Merged
Conversation
…$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>
Signed-off-by: Ho Lim <subhoya@gmail.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.
Contributor
૮ >ﻌ< ა ci reviewran on 3d6c32b ℹ️ InfoDesktop E2E visual evidence · View test artifacts · View job1 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) |
This was referenced Jul 28, 2026
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.infoheartbeat republished the whole store even when nothing changed:updateSessionStatealways spread a fresh object,publishSessionStatealways 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 intoensureSessionStateis 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
messageComponentsmap listed theonBranchInNewChat/onCancelcallbacks 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-splitthread/index.tsxand 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: falseis 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)
$focusedSessionStatewhole (republished per delta, mounted app-wide) to readmodel/provider— re-running the un-memoized closed dialog ~30×/s during a stream. Now twouseStoreSelectorscalars.$statusItemsBySessionmap, 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 viauseSessionSlice.$subagentsBySessionfor two counts,$sessionsfor one row'sstarted_at. Both are scalar selections now.$sessions+$projectTreefor 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-remountregression test: red with the fix reverted against main, green heretsc --noEmitand eslint clean on touched filesCloses #69030. Closes #44884. Closes #66160. Fixes #69016.