MoonshotAI / MoonshotAI/kimi-code
Web UI: unbounded DOM node / listener / heap growth in long sessions (detached DOM leak, no list virtualization, per-instance global scroll listeners)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 7.5k
- Forks
- 1.2k
- Avg merge
- 11h 53m
- Merged PRs (30d)
- 350
Description
Title: Web UI: unbounded DOM node / listener / heap growth in long sessions — detached DOM leak, no list virtualization, per-instance global scroll listeners
Version / Environment
- Kimi Code: 0.31.1 (SEA binary
kimi.exe web, Windows x64) - OS: Windows 11 (10.0.26200)
- Browser: Microsoft Edge (Chromium, latest stable)
- Related: #749, #122
Summary
In the Web UI (apps/kimi-web), a long-lived session page accumulates DOM nodes, JS heap and event listeners monotonically until scrolling/typing becomes visibly laggy. A forced GC does not reclaim the nodes, and a heap snapshot shows ~377k detached DOM nodes. Refreshing the page immediately resets everything, confirming page-lifetime accumulation rather than a browser/OS issue (this is likely the root cause behind the symptoms in #749 and #122).
Measured evidence (via CDP Performance.getMetrics on the live page)
| State | DOM Nodes | JS Heap | JS Event Listeners |
|---|---|---|---|
| Laggy (page open ~long session) | 677,401 | 714 MB | 30,238 |
| Immediately after F5 | 34,603 | 54 MB | 2,236 |
| 20 min after refresh (light use) | 140,750 | 195 MB | 7,780 |
- Forced
HeapProfiler.collectGarbage(×2) does not reduce the node count → genuinely retained, not GC lag. - Idle (no streaming): 0 long tasks in an 8s window — the jank appears during interaction (scroll/type/new message render), consistent with thousands of per-instance listeners and layout/GC pressure from the huge node pool.
- A same-browser control group (other heavy SPA tabs: 8k–80k nodes) shows no such growth.
Heap snapshot analysis (1.5 GB snapshot, full parse — 11.8M records)
377,611 detached DOM nodes, top offenders:
- 109,016 × detached
<div data-v-262bbea0>(=ToolOutputBlock.vueroot/line divs) + 108,705 detachedTextnodes (1:1 ratio — rebuilt tool-output block corpses) - 10,892 × detached
SVGSVGElement+ 10,887 ×SVGPathElement - 4,543 × detached
<span class="ui-tip">(Tooltip), thousands of code-block / think-block remnants
Attached heap highlights: two giant Shiki tokenizer regex strings (94 MB + 34 MB), 82 MB ExternalStringData, 1.97M plain Objects (full session state), 270k blink::UniqueElementData.
Root causes (code-level, all in apps/kimi-web/src)
- No list virtualization —
components/chat/ChatPane.vue:550renders every turn withv-for; messages never unmount. All other issues multiply with session length. - Per-message MutationObserver + DOM rewriting —
components/chat/Markdown.vue:299-309: each Markdown instance observes its whole subtree; stream-diffs/shiki re-renders trigger a full-subtree TreeWalker +replaceChild+ per-linkaddEventListeneron every mutation batch. The observer even runs during streaming, whenprocessFileLinksearly-returns anyway. - Per-Tooltip global capture-phase scroll listener —
components/ui/Tooltip.vue:142-143: every instance keepswindow.addEventListener('scroll', ..., true)+resizeregistered for its entire lifetime, while the callback is a no-op when closed. Hundreds of instances × every scroll event = scroll jank without any single long task. - Amplifier for the detached nodes: every
toolOutputevent immutably replaces the whole messages array (api/daemon/eventReducer.ts:569-574), so each streaming chunk re-diffs the entire chat list; turn blocks also flip keys between'tool'/'tool-stack'(chatTurnRendering.ts:119-125), remounting subtrees. The 109k detached ToolOutputBlock divs match this churn (the exact retainer still needs a Retainers-chain check — likely inside markstream-vue or a closure).
Suggested fixes (all small, except the last)
Tooltip.vue: lazily register the windowscroll/resizelisteners inshow(), remove inhide()/onBeforeUnmount(~8 lines, behavior-equivalent).Markdown.vue: don'tobserve()whileprops.streaming === true; add a ~250 ms trailing throttle toscheduleFileLinkProcessing(~10 lines).ChatPane.vue: render only the latest N turns (e.g. 200) with a "show earlier" button as a lightweight alternative to full virtualization (~30-60 lines) — the only fix that actually caps DOM node count, Tooltip instance count, and markstream mounts.- Investigate the detached-node retainer for ToolOutputBlock subtrees (heap snapshot Retainers panel; component itself and ancestors are clean — no missing
onUnmounted, noKeepAlive, no DOM-in-reactive-state).
Reproduction
kimi web, open a session with long history (or let a streaming task run for ~30 min).- Keep scrolling / interacting; observe growing lag.
Performance.getMetricsshows Nodes/heap/listeners monotonically increasing; forced GC does not reduce Nodes.- F5 → everything resets; lag disappears.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the reported entry points in apps/kimi-web/src: ChatPane.vue:550, Markdown.vue:299-309, Tooltip.vue:142-143, eventReducer.ts:569-574, and chatTurnRendering.ts:119-125. Reproduce a long session with CDP Performance.getMetrics and inspect the heap snapshot Retainers panel for the ToolOutputBlock subtrees. Done means the identified growth paths are addressed and node, heap, and listener growth no longer accumulates during the reported interaction.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- frontend, performance, web-dev
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 42/100