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.
This commit is contained in:
Waleed
2026-07-17 16:49:14 -07:00
committed by GitHub
parent a4e2fb94ac
commit 87edbc5d54
@@ -11,7 +11,7 @@ import 'prismjs/components/prism-bash'
import 'prismjs/components/prism-css'
import 'prismjs/components/prism-markup'
import '@sim/emcn/components/code/code.css'
import { Checkbox, CopyCodeButton, cn, highlight, languages } from '@sim/emcn'
import { Checkbox, CopyCodeButton, cn, languages, highlight as prismHighlight } from '@sim/emcn'
import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils'
import { extractTextContent } from '@/lib/core/utils/react-node-text'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
@@ -161,6 +161,38 @@ function fileIconLabel(ref: string, fallback: string): string {
return fallback
}
/**
* Bounded LRU cache for Prism highlight output. Chat rows are virtualized, so a
* message re-highlights every time it scrolls back into view; a component
* `useMemo` would not survive the unmount, so the cache lives at module scope.
* Output for an unregistered language is never cached — it renders through the
* JavaScript fallback, so caching it would keep serving that stale render if the
* real grammar registers later in the session.
*/
const HIGHLIGHT_CACHE_LIMIT = 512
const highlightCache = new Map<string, string>()
function highlight(code: string, language: string): string {
const resolved = LANG_ALIASES[language] || language || 'javascript'
const grammar = languages[resolved]
if (!grammar) return prismHighlight(code, languages.javascript, resolved)
const key = `${resolved}\n${code}`
const cached = highlightCache.get(key)
if (cached !== undefined) {
highlightCache.delete(key)
highlightCache.set(key, cached)
return cached
}
const html = prismHighlight(code, grammar, resolved)
highlightCache.set(key, html)
if (highlightCache.size > HIGHLIGHT_CACHE_LIMIT) {
const oldest = highlightCache.keys().next().value
if (oldest !== undefined) highlightCache.delete(oldest)
}
return html
}
const MARKDOWN_COMPONENTS = {
table({ children }: { children?: React.ReactNode }) {
return (
@@ -207,9 +239,7 @@ const MARKDOWN_COMPONENTS = {
)
}
const resolved = LANG_ALIASES[language] || language || 'javascript'
const grammar = languages[resolved] || languages.javascript
const html = highlight(codeString.trimEnd(), grammar, resolved)
const html = highlight(codeString.trimEnd(), language)
return (
<div className='not-prose my-6 overflow-hidden rounded-lg border border-[var(--divider)]'>
@@ -412,9 +442,9 @@ function ChatContentInner({
* position (`E`/`qe` in streamdown 2.5), so a re-parse of unchanged content
* without the animate plugin bails at every unoverridden element (`p`,
* `strong`, `tr`, headings, …) and leaves the stale per-char span DOM in
* place. Every instance renders through the streaming parser (see
* `streamingTree` below) so the remount only sheds the spans, never
* re-interprets the markdown.
* place. The settled instance keeps the streaming parser (`parserTree`
* below) so the remount only sheds the spans, never re-interprets the
* markdown.
*
* The drain is deliberately one-way: a stream that resumes afterwards
* (reconnect/continuation) reveals paced but unfaded, because re-arming
@@ -459,18 +489,19 @@ function ChatContentInner({
}, [isRevealing, animationDrained, streamedThisSession])
/**
* Every mount renders through the streaming parser (remend +
* incomplete-markdown repair + block-split) — `mode='static'` is never used.
* The two pipelines parse edge-case markdown differently (unbalanced fences,
* list continuation across blocks), so a message you watched stream would
* render subtly differently from the same message reloaded from the DB; one
* pipeline makes in-session and refreshed renders byte-identical. The rows
* are virtualized, so only visible messages pay the block-split mount cost.
* `streamingTree` (the remount key and animation props) still drops at
* drain, so a settled instance re-renders through the SAME parser minus the
* per-word animation spans — identical pixels.
* `parserTree` (drives `mode`) stays latched for the mount's life: streaming
* mode is the only one that applies remend/incomplete-markdown repair and
* block-split parsing, so a settled message must KEEP the streaming parser —
* swapping to `mode='static'` at drain re-parses the same source through a
* different pipeline (no remend, whole-doc parse) and visibly flashes on any
* reply with unbalanced markdown. `streamingTree` (drives the remount key
* and animation props) additionally drops at drain, so the settled instance
* re-renders through the SAME parser minus the per-word animation spans —
* byte-identical pixels. Only never-streamed mounts (reloaded history)
* render static.
*/
const streamingTree = (isRevealing || streamedThisSession) && !animationDrained
const parserTree = isRevealing || streamedThisSession
const streamingTree = parserTree && !animationDrained
/**
* One-way fade cutoff (see {@link FADE_MAX_REVEALED_CHARS}). Latched so a
@@ -573,6 +604,7 @@ function ChatContentInner({
>
<Streamdown
key={streamingTree ? 'stream' : 'settled'}
mode={parserTree ? undefined : 'static'}
animated={fadeActive ? STREAM_ANIMATION : false}
isAnimating={streamingTree}
components={MARKDOWN_COMPONENTS}