Problem
Six targeted GC/memory improvements identified through a full audit of the GC stack (chat.js, chatHistory.js, markdown.js, qt_wrapper.py):
-
Missing idle GC signal after _evictLive and _pruneBottom — _pruneTop already yields with requestIdleCallback(() => {}, { timeout: 3000 }) after detaching subtrees. _evictLive and _pruneBottom create detached subtrees but never yield, leaving V8/Oilpan without the idle hint needed to trigger incremental collection.
-
Missing timer/renderer teardown in _pruneTop and _pruneBottom — _evictLive correctly clears _waveInterval, _elapsedTicker, and _streamRenderer on every removed element and its descendants before .remove(). _pruneTop and _pruneBottom only call hljsDeferForgetNode, leaving live timers on detached nodes.
-
squashOutsideCode allocates on every render frame — called at ~30 fps during streaming. For the common case (plain text, no code blocks) it allocates a split array and join string that are immediately discarded. A str.includes('```') guard short-circuits the whole allocation.
-
7 direct window.hljs.highlightElement calls bypass deferHighlightAll — deferHighlightAll (already imported) uses IntersectionObserver to highlight only visible code blocks. 4 of the 7 sites highlight off-screen blocks synchronously, blocking the main thread on large responses.
-
_purgeStaleBackgroundStreams called only on chat submit — completed/error background stream Map entries persist across session switches and accumulate until the next submit. checkBackgroundStream is invoked on every session switch and is the correct place to run the purge.
Fix
- A: Add
requestIdleCallback(() => {}, { timeout: 3000 }) at the end of _evictLive and inside the if (removed > 0) block of _pruneBottom.
- A+: Add full timer/renderer teardown (
_waveInterval, _elapsedTicker, _streamRenderer + descendant walk) before each .remove() call in _pruneTop and _pruneBottom, mirroring the pattern already in _evictLive.
- B: Add
if (!str.includes('```')) { return str.replace(...); } fast path to squashOutsideCode in markdown.js.
- C: Replace 7
window.hljs.highlightElement forEach loops in chat.js with deferHighlightAll(container).
- F: Call
_purgeStaleBackgroundStreams() at the top of checkBackgroundStream in chat.js.
Files
static/js/chatHistory.js — Changes A, A+
static/js/markdown.js — Change B
static/js/chat.js — Changes C, F
Tests
15 new static-analysis tests across 4 files:
tests/test_chat_history_js.py (+4)
tests/test_markdown_squash_js.py (new, 3)
tests/test_chat_hljs_defer_js.py (new, 3)
tests/test_chat_gc_hint_js.py (+1)
Note: V8/Chromium flag changes (--initial-old-space-size, --optimize-for-size, --minor-mc, --renderer-process-limit=1, --disable-extensions) are tracked separately on the Qt wrapper branch.
Problem
Six targeted GC/memory improvements identified through a full audit of the GC stack (chat.js, chatHistory.js, markdown.js, qt_wrapper.py):
Missing idle GC signal after
_evictLiveand_pruneBottom—_pruneTopalready yields withrequestIdleCallback(() => {}, { timeout: 3000 })after detaching subtrees._evictLiveand_pruneBottomcreate detached subtrees but never yield, leaving V8/Oilpan without the idle hint needed to trigger incremental collection.Missing timer/renderer teardown in
_pruneTopand_pruneBottom—_evictLivecorrectly clears_waveInterval,_elapsedTicker, and_streamRendereron every removed element and its descendants before.remove()._pruneTopand_pruneBottomonly callhljsDeferForgetNode, leaving live timers on detached nodes.squashOutsideCodeallocates on every render frame — called at ~30 fps during streaming. For the common case (plain text, no code blocks) it allocates asplitarray andjoinstring that are immediately discarded. Astr.includes('```')guard short-circuits the whole allocation.7 direct
window.hljs.highlightElementcalls bypassdeferHighlightAll—deferHighlightAll(already imported) uses IntersectionObserver to highlight only visible code blocks. 4 of the 7 sites highlight off-screen blocks synchronously, blocking the main thread on large responses._purgeStaleBackgroundStreamscalled only on chat submit — completed/error background stream Map entries persist across session switches and accumulate until the next submit.checkBackgroundStreamis invoked on every session switch and is the correct place to run the purge.Fix
requestIdleCallback(() => {}, { timeout: 3000 })at the end of_evictLiveand inside theif (removed > 0)block of_pruneBottom._waveInterval,_elapsedTicker,_streamRenderer+ descendant walk) before each.remove()call in_pruneTopand_pruneBottom, mirroring the pattern already in_evictLive.if (!str.includes('```')) { return str.replace(...); }fast path tosquashOutsideCodeinmarkdown.js.window.hljs.highlightElementforEach loops inchat.jswithdeferHighlightAll(container)._purgeStaleBackgroundStreams()at the top ofcheckBackgroundStreaminchat.js.Files
static/js/chatHistory.js— Changes A, A+static/js/markdown.js— Change Bstatic/js/chat.js— Changes C, FTests
15 new static-analysis tests across 4 files:
tests/test_chat_history_js.py(+4)tests/test_markdown_squash_js.py(new, 3)tests/test_chat_hljs_defer_js.py(new, 3)tests/test_chat_gc_hint_js.py(+1)Note: V8/Chromium flag changes (
--initial-old-space-size,--optimize-for-size,--minor-mc,--renderer-process-limit=1,--disable-extensions) are tracked separately on the Qt wrapper branch.