Skip to content

Commit 87edbc5

Browse files
authored
perf(mothership): restore static markdown parse for settled chat messages (#5751)
* 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). * fix(mothership): don't cache fallback-highlighted code blocks An unregistered language highlighted via the JavaScript fallback was cached under its own name, so if that Prism grammar registered later in the session a remount would keep serving the stale fallback render. Resolve the grammar inside the highlight helper and skip the cache entirely on the fallback path.
1 parent a4e2fb9 commit 87edbc5

1 file changed

Lines changed: 50 additions & 18 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: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import 'prismjs/components/prism-bash'
1111
import 'prismjs/components/prism-css'
1212
import 'prismjs/components/prism-markup'
1313
import '@sim/emcn/components/code/code.css'
14-
import { Checkbox, CopyCodeButton, cn, highlight, languages } from '@sim/emcn'
14+
import { Checkbox, CopyCodeButton, cn, languages, highlight as prismHighlight } from '@sim/emcn'
1515
import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils'
1616
import { extractTextContent } from '@/lib/core/utils/react-node-text'
1717
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
@@ -161,6 +161,38 @@ function fileIconLabel(ref: string, fallback: string): string {
161161
return fallback
162162
}
163163

164+
/**
165+
* Bounded LRU cache for Prism highlight output. Chat rows are virtualized, so a
166+
* message re-highlights every time it scrolls back into view; a component
167+
* `useMemo` would not survive the unmount, so the cache lives at module scope.
168+
* Output for an unregistered language is never cached — it renders through the
169+
* JavaScript fallback, so caching it would keep serving that stale render if the
170+
* real grammar registers later in the session.
171+
*/
172+
const HIGHLIGHT_CACHE_LIMIT = 512
173+
const highlightCache = new Map<string, string>()
174+
175+
function highlight(code: string, language: string): string {
176+
const resolved = LANG_ALIASES[language] || language || 'javascript'
177+
const grammar = languages[resolved]
178+
if (!grammar) return prismHighlight(code, languages.javascript, resolved)
179+
180+
const key = `${resolved}\n${code}`
181+
const cached = highlightCache.get(key)
182+
if (cached !== undefined) {
183+
highlightCache.delete(key)
184+
highlightCache.set(key, cached)
185+
return cached
186+
}
187+
const html = prismHighlight(code, grammar, resolved)
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 (
@@ -207,9 +239,7 @@ const MARKDOWN_COMPONENTS = {
207239
)
208240
}
209241

210-
const resolved = LANG_ALIASES[language] || language || 'javascript'
211-
const grammar = languages[resolved] || languages.javascript
212-
const html = highlight(codeString.trimEnd(), grammar, resolved)
242+
const html = highlight(codeString.trimEnd(), language)
213243

214244
return (
215245
<div className='not-prose my-6 overflow-hidden rounded-lg border border-[var(--divider)]'>
@@ -412,9 +442,9 @@ function ChatContentInner({
412442
* position (`E`/`qe` in streamdown 2.5), so a re-parse of unchanged content
413443
* without the animate plugin bails at every unoverridden element (`p`,
414444
* `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.
445+
* place. The settled instance keeps the streaming parser (`parserTree`
446+
* below) so the remount only sheds the spans, never re-interprets the
447+
* markdown.
418448
*
419449
* The drain is deliberately one-way: a stream that resumes afterwards
420450
* (reconnect/continuation) reveals paced but unfaded, because re-arming
@@ -459,18 +489,19 @@ function ChatContentInner({
459489
}, [isRevealing, animationDrained, streamedThisSession])
460490

461491
/**
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.
492+
* `parserTree` (drives `mode`) stays latched for the mount's life: streaming
493+
* mode is the only one that applies remend/incomplete-markdown repair and
494+
* block-split parsing, so a settled message must KEEP the streaming parser —
495+
* swapping to `mode='static'` at drain re-parses the same source through a
496+
* different pipeline (no remend, whole-doc parse) and visibly flashes on any
497+
* reply with unbalanced markdown. `streamingTree` (drives the remount key
498+
* and animation props) additionally drops at drain, so the settled instance
499+
* re-renders through the SAME parser minus the per-word animation spans —
500+
* byte-identical pixels. Only never-streamed mounts (reloaded history)
501+
* render static.
472502
*/
473-
const streamingTree = (isRevealing || streamedThisSession) && !animationDrained
503+
const parserTree = isRevealing || streamedThisSession
504+
const streamingTree = parserTree && !animationDrained
474505

475506
/**
476507
* One-way fade cutoff (see {@link FADE_MAX_REVEALED_CHARS}). Latched so a
@@ -573,6 +604,7 @@ function ChatContentInner({
573604
>
574605
<Streamdown
575606
key={streamingTree ? 'stream' : 'settled'}
607+
mode={parserTree ? undefined : 'static'}
576608
animated={fadeActive ? STREAM_ANIMATION : false}
577609
isAnimating={streamingTree}
578610
components={MARKDOWN_COMPONENTS}

0 commit comments

Comments
 (0)