Skip to content

Commit 4d39557

Browse files
committed
perf(mothership): restore static markdown parse for settled chat messages
Settled/reloaded chat messages rendered through Streamdown's streaming parser (remend + incomplete-markdown repair + per-block re-parse, running the rehype raw/sanitize/harden chain once per block). Because rows are virtualized, every up/down scroll remounted the messages crossing the overscan boundary and re-paid that N-block cost — the scroll lag. - Render never-streamed mounts (reloaded history, or an in-session message scrolled out of the virtualized window and back) with mode='static': one whole-document parse instead of streaming's per-block re-parse. In-session streaming keeps the streaming parser for its mount life (no drain flash). - Cache Prism highlight output in a module-level bounded LRU so a code block re-highlights at most once across the unmount/remount virtualization does on scroll (a component useMemo would not survive the unmount).
1 parent 7e975e7 commit 4d39557

1 file changed

Lines changed: 58 additions & 15 deletions

File tree

  • apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import 'prismjs/components/prism-typescript'
1010
import 'prismjs/components/prism-bash'
1111
import 'prismjs/components/prism-css'
1212
import 'prismjs/components/prism-markup'
13+
import type { Grammar } from 'prismjs'
1314
import '@sim/emcn/components/code/code.css'
14-
import { Checkbox, CopyCodeButton, cn, highlight, languages } from '@sim/emcn'
15+
import { Checkbox, CopyCodeButton, cn, languages, highlight as prismHighlight } from '@sim/emcn'
1516
import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils'
1617
import { extractTextContent } from '@/lib/core/utils/react-node-text'
1718
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
@@ -161,6 +162,37 @@ function fileIconLabel(ref: string, fallback: string): string {
161162
return fallback
162163
}
163164

165+
/**
166+
* Prism highlighting is a pure function of (grammar, source) but costs several
167+
* milliseconds per block. Chat rows are virtualized, so a message re-highlights
168+
* every time it scrolls back into the overscan window — and a component-level
169+
* `useMemo` would not survive the unmount between those scrolls. This
170+
* module-level cache keeps the highlighted HTML across remounts, keyed by
171+
* resolved language + source, so scrolling a code-heavy transcript re-highlights
172+
* each block at most once. It is bounded by {@link HIGHLIGHT_CACHE_LIMIT} with
173+
* LRU eviction so a long session cannot grow it without limit.
174+
*/
175+
const HIGHLIGHT_CACHE_LIMIT = 512
176+
const highlightCache = new Map<string, string>()
177+
178+
function highlight(code: string, grammar: Grammar | undefined, language: string): string {
179+
const key = `${language}\n${code}`
180+
const cached = highlightCache.get(key)
181+
if (cached !== undefined) {
182+
// Move the hit to the newest slot so eviction sheds genuinely cold entries.
183+
highlightCache.delete(key)
184+
highlightCache.set(key, cached)
185+
return cached
186+
}
187+
const html = prismHighlight(code, grammar, language)
188+
highlightCache.set(key, html)
189+
if (highlightCache.size > HIGHLIGHT_CACHE_LIMIT) {
190+
const oldest = highlightCache.keys().next().value
191+
if (oldest !== undefined) highlightCache.delete(oldest)
192+
}
193+
return html
194+
}
195+
164196
const MARKDOWN_COMPONENTS = {
165197
table({ children }: { children?: React.ReactNode }) {
166198
return (
@@ -412,9 +444,9 @@ function ChatContentInner({
412444
* position (`E`/`qe` in streamdown 2.5), so a re-parse of unchanged content
413445
* without the animate plugin bails at every unoverridden element (`p`,
414446
* `strong`, `tr`, headings, …) and leaves the stale per-char span DOM in
415-
* place. Every instance renders through the streaming parser (see
416-
* `streamingTree` below) so the remount only sheds the spans, never
417-
* re-interprets the markdown.
447+
* place. The settled instance keeps the streaming parser (`parserTree`
448+
* below) so the remount only sheds the spans, never re-interprets the
449+
* markdown.
418450
*
419451
* The drain is deliberately one-way: a stream that resumes afterwards
420452
* (reconnect/continuation) reveals paced but unfaded, because re-arming
@@ -459,18 +491,28 @@ function ChatContentInner({
459491
}, [isRevealing, animationDrained, streamedThisSession])
460492

461493
/**
462-
* Every mount renders through the streaming parser (remend +
463-
* incomplete-markdown repair + block-split) — `mode='static'` is never used.
464-
* The two pipelines parse edge-case markdown differently (unbalanced fences,
465-
* list continuation across blocks), so a message you watched stream would
466-
* render subtly differently from the same message reloaded from the DB; one
467-
* pipeline makes in-session and refreshed renders byte-identical. The rows
468-
* are virtualized, so only visible messages pay the block-split mount cost.
469-
* `streamingTree` (the remount key and animation props) still drops at
470-
* drain, so a settled instance re-renders through the SAME parser minus the
471-
* per-word animation spans — identical pixels.
494+
* `parserTree` (drives `mode`) stays latched for the mount's life: streaming
495+
* mode is the only one that applies remend/incomplete-markdown repair and
496+
* block-split parsing, so an in-session message must KEEP the streaming
497+
* parser once it has streamed — swapping to `mode='static'` at drain
498+
* re-parses the same source through a different pipeline (no remend,
499+
* whole-doc parse) and visibly flashes on any reply with unbalanced markdown.
500+
* `streamingTree` (drives the remount key and animation props) additionally
501+
* drops at drain, so the settled instance re-renders through the SAME parser
502+
* minus the per-word animation spans — byte-identical pixels.
503+
*
504+
* A NEVER-streamed mount — reloaded history, or an in-session message
505+
* scrolled out of the virtualized window and back — has `parserTree === false`
506+
* for its whole life and renders `mode='static'`: one whole-document parse
507+
* instead of streaming's per-block re-parse, which runs the rehype
508+
* raw/sanitize/harden chain once PER block. Because chat rows are virtualized,
509+
* every up/down scroll remounts the messages crossing the overscan boundary;
510+
* paying streaming's N-block cost on those reloads is what made scrolling long
511+
* transcripts lag, so the cheap static parse is the correct mode for settled
512+
* content (a completed message needs no incomplete-markdown repair).
472513
*/
473-
const streamingTree = (isRevealing || streamedThisSession) && !animationDrained
514+
const parserTree = isRevealing || streamedThisSession
515+
const streamingTree = parserTree && !animationDrained
474516

475517
/**
476518
* One-way fade cutoff (see {@link FADE_MAX_REVEALED_CHARS}). Latched so a
@@ -573,6 +615,7 @@ function ChatContentInner({
573615
>
574616
<Streamdown
575617
key={streamingTree ? 'stream' : 'settled'}
618+
mode={parserTree ? undefined : 'static'}
576619
animated={fadeActive ? STREAM_ANIMATION : false}
577620
isAnimating={streamingTree}
578621
components={MARKDOWN_COMPONENTS}

0 commit comments

Comments
 (0)