From b79985a44a800049d21e3631cbc00db8209dd05b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 16:32:09 -0700 Subject: [PATCH 1/2] perf(mothership): restore static markdown parse for settled chat messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../components/chat-content/chat-content.tsx | 58 ++++++++++++++----- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 1047fafa3e9..eab4567efe1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -10,8 +10,9 @@ import 'prismjs/components/prism-typescript' import 'prismjs/components/prism-bash' import 'prismjs/components/prism-css' import 'prismjs/components/prism-markup' +import type { Grammar } from 'prismjs' 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 +162,31 @@ 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. + */ +const HIGHLIGHT_CACHE_LIMIT = 512 +const highlightCache = new Map() + +function highlight(code: string, grammar: Grammar | undefined, language: string): string { + const key = `${language}\n${code}` + const cached = highlightCache.get(key) + if (cached !== undefined) { + highlightCache.delete(key) + highlightCache.set(key, cached) + return cached + } + const html = prismHighlight(code, grammar, language) + 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 ( @@ -412,9 +438,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 +485,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 +600,7 @@ function ChatContentInner({ > Date: Fri, 17 Jul 2026 16:40:44 -0700 Subject: [PATCH 2/2] 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. --- .../components/chat-content/chat-content.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index eab4567efe1..971732b747c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -10,7 +10,6 @@ import 'prismjs/components/prism-typescript' import 'prismjs/components/prism-bash' import 'prismjs/components/prism-css' import 'prismjs/components/prism-markup' -import type { Grammar } from 'prismjs' import '@sim/emcn/components/code/code.css' import { Checkbox, CopyCodeButton, cn, languages, highlight as prismHighlight } from '@sim/emcn' import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils' @@ -166,19 +165,26 @@ function fileIconLabel(ref: string, fallback: string): string { * 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() -function highlight(code: string, grammar: Grammar | undefined, language: string): string { - const key = `${language}\n${code}` +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, language) + const html = prismHighlight(code, grammar, resolved) highlightCache.set(key, html) if (highlightCache.size > HIGHLIGHT_CACHE_LIMIT) { const oldest = highlightCache.keys().next().value @@ -233,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 (