perf(transcript): default-OFF DOM virtualization means long sessions render O(N) nodes and full-rebuild on every update
Thinking Path
- Hermes WebUI streams live chat turns efficiently (incremental markdown parser, throttled ~15fps token append), and the normal session-open path fetches a bounded ~30-message tail window.
- But transcript rendering into the DOM has no windowing by default: every renderable message becomes a DOM node that stays in the tree, and
renderMessages()does a full wipe-and-rebuild on every in-session update. - A virtualization subsystem exists (
MESSAGE_VIRTUAL_THRESHOLD_ROWS,MESSAGE_RENDER_WINDOW_DEFAULT) but is intentionally OFF by default (#4343, for scroll-up flicker). So a long conversation is O(N) DOM nodes and O(N) work per update. - This is distinct from #5839 (reasoning/journal-specific blowup, closed) — it's the general case: any long session, any model.
What's happening
1. No DOM windowing by default — O(N) nodes for the whole transcript.
Transcript virtualization is experimental and default-OFF:
static/boot.js:3165-3168—#4343: transcript virtualization is EXPERIMENTAL/opt-IN (default OFF)... window._virtualizeTranscript=s.virtualize_transcript===true;static/boot.js:3319— on settings-load failure,window._virtualizeTranscript=false;(default OFF).static/ui.js:826-836—_currentMessageVirtualWindow()short-circuits to a fully non-windowed result when virtualization is off: returns{virtualized:false, start:0, end:total, ...}— i.e. the wholevisWithIdx.- The render loop at
static/ui.js:15533+then builds a<div class="msg-row">for every message, all appended to#msgInner. Node recycling (_msgNodeRecycleEnabled,_recycleStash) only activates inside the virtualized branch.
So in the default configuration, a 10,000-message conversation produces 10,000 DOM nodes that all stay in the tree. Layout/style/paint cost grows with total transcript length, not viewport size.
2. renderMessages() is a full wipe-and-rebuild, called on every in-session update.
static/ui.js:15357—inner.innerHTML='';(the wipe), then the rebuild loop re-parses markdown (_getCachedRender) and reconstructs every row's HTML.- It's called from 113 sites across the frontend (
grep renderMessages( static/*.js | wc -l), including every: tool completion (static/messages.js:5860, 6138, 6143, 6344, 6374, 6523, 6587, 6633, 8148, 8594), stream done/settle, edit, error, and new user message. - Each such call is O(N) over the whole transcript. On a large session, a single tool finishing re-renders every prior message row on the main thread.
There IS a per-session HTML cache (_sessionHtmlCache, static/ui.js:14334, LRU-bounded at 8 entries × 300KB) that helps on switch-back-to-a-previously-rendered-session, but it's bypassed while streaming and doesn't help in-session updates.
Why it matters
Memory and responsiveness scale with total transcript length, not the viewport. On a long agentic session (hundreds–thousands of messages, each possibly carrying large tool outputs), the default config holds every node in the DOM and re-renders all of them on each tool/stream-done event. This is the structural cause behind the "browser freeze on long sessions" class of reports (cf. #5839, #4325, #5636, #4277).
What already exists (so this is a re-enable + stabilize task, not greenfield)
- Windowing math:
_messageVirtualWindow()(static/ui.js:836+) — computes a visible slice from scrollTop/viewportHeight/heights. - Threshold + window size:
MESSAGE_VIRTUAL_THRESHOLD_ROWS=80,MESSAGE_RENDER_WINDOW_DEFAULT=50(static/ui.js:524-525). - Height cache:
_messageVirtualHeightCache(static/ui.js:542), cleared on session change. - Top/bottom padding spacers for scroll position preservation.
- Node recycling stash (
_recycleStash).
So the machinery is there. The blocker to defaulting it back ON is the scroll-up flicker / oscillation that #4343 disabled it for (and #4346's Phase B root-cause fix is closed — worth checking whether the variable-height-anchor oscillation it targeted is actually resolved).
Suggested direction (not prescribing)
- Revisit why #4343 defaulted it off — is the #4346 Phase B fix (variable-height anchor oscillation) actually resolving the flicker, or was it closed without re-enabling?
- If the flicker root cause is fixed, re-default virtualization ON behind the existing threshold (
MESSAGE_VIRTUAL_THRESHOLD_ROWS=80) so only long sessions pay the windowing complexity. - As a smaller intermediate step even with virtualization off: cap the rendered DOM to the tail window the server already sends (
msg_limit) and gate older rows behind the existing "Load earlier" button (static/ui.js:1531) — the infrastructure is present but only wired when virtualization is on.
Verification I did (evidence)
- Confirmed
window._virtualizeTranscript===falseis the default on both the success and settings-failure paths (boot.js:3168, 3319). - Confirmed the OFF path returns
start:0, end:total(ui.js:829-835). - Confirmed
renderMessageswipes and rebuilds (ui.js:15357) and is called from 113 sites. - Confirmed streaming tokens do NOT go through
renderMessagesper-token (they append to one live node viawindow.smd), so the per-token path is fine — it's the per-update full re-render that's costly.
Scope note
This is a frontend rendering concern, separate from the backend bounded-read work (a server-side msg_limit ceiling and bounded state.db read are being addressed separately). Even with backend pagination, the DOM still holds everything the server sent unless virtualization/windowing is enabled — so both layers need bounding for the long-session case.
Model Used
builtin:zai-coding-plan/GLM-5.2 (ZCode agent). Audit done by reading static/boot.js, static/ui.js, static/messages.js, static/sessions.js against the current master.
Source: nesquena/hermes-webui