fix(ui): avoid caching incomplete code highlighting

Only cache markdown HTML after Shiki has the required fence languages loaded so virtualized assistant messages can re-render with syntax highlighting when remounted.
This commit is contained in:
Shantur
2026-03-31 15:18:44 +01:00
parent 1d953dfe64
commit fe932c8307
2 changed files with 46 additions and 12 deletions

View File

@@ -123,7 +123,11 @@ export function Markdown(props: MarkdownProps) {
version: () => resolved().version, version: () => resolved().version,
}) })
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => { const commitCacheEntry = (
snapshot: ReturnType<typeof resolved>,
renderedHtml: string,
options?: { cache?: boolean },
) => {
const cacheEntry: RenderCache = { const cacheEntry: RenderCache = {
text: snapshot.text, text: snapshot.text,
html: renderedHtml, html: renderedHtml,
@@ -131,7 +135,9 @@ export function Markdown(props: MarkdownProps) {
mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`, mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`,
} }
setHtml(renderedHtml) setHtml(renderedHtml)
cacheHandle.set(cacheEntry) if (options?.cache ?? true) {
cacheHandle.set(cacheEntry)
}
notifyRendered() notifyRendered()
} }
@@ -142,9 +148,10 @@ export function Markdown(props: MarkdownProps) {
suppressHighlight: !snapshot.highlightEnabled, suppressHighlight: !snapshot.highlightEnabled,
escapeRawHtml: snapshot.escapeRawHtml, escapeRawHtml: snapshot.escapeRawHtml,
}) })
const shouldCache = !snapshot.highlightEnabled || !markdown.hasPendingCodeHighlight(snapshot.text)
if (latestRequestKey === snapshot.requestKey) { if (latestRequestKey === snapshot.requestKey) {
commitCacheEntry(snapshot, rendered) commitCacheEntry(snapshot, rendered, { cache: shouldCache })
} }
} }

View File

@@ -120,14 +120,7 @@ function resolveLanguage(token: string): { canonical: string | null; raw: string
return { canonical: null, raw: normalized } return { canonical: null, raw: normalized }
} }
async function ensureLanguages(content: string) { function collectCodeFenceLanguages(content: string): string[] {
if (highlightSuppressed) {
return
}
// Extract code-fence language tokens via `marked` so we correctly handle code blocks
// that contain backticks (e.g. JS template literals). Regex-based fence scans tend
// to miss these and prevent languages from loading.
const foundLanguages = new Set<string>() const foundLanguages = new Set<string>()
try { try {
const tokens = marked.lexer(content) as any const tokens = marked.lexer(content) as any
@@ -139,10 +132,44 @@ async function ensureLanguages(content: string) {
} }
}) })
} catch { } catch {
// If tokenization fails for any reason, skip language preloading. return []
}
return [...foundLanguages]
}
export function hasPendingCodeHighlight(content: string): boolean {
const languages = collectCodeFenceLanguages(content)
for (const token of languages) {
const rawToken = normalizeLanguageToken(token)
if (!rawToken || rawToken === "text") {
continue
}
const { canonical, raw } = resolveLanguage(token)
const langKey = canonical || raw
if (langKey === "text" || raw === "text") {
continue
}
if (!highlighter || !loadedLanguages.has(langKey)) {
return true
}
}
return false
}
async function ensureLanguages(content: string) {
if (highlightSuppressed) {
return return
} }
// Extract code-fence language tokens via `marked` so we correctly handle code blocks
// that contain backticks (e.g. JS template literals). Regex-based fence scans tend
// to miss these and prevent languages from loading.
const foundLanguages = collectCodeFenceLanguages(content)
// Queue language loading tasks // Queue language loading tasks
for (const token of foundLanguages) { for (const token of foundLanguages) {
const rawToken = normalizeLanguageToken(token) const rawToken = normalizeLanguageToken(token)