From 77479127a5b5b083c9507f3bf0745a9485eca5c1 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 22 Aug 2026 02:10:03 +0300 Subject: [PATCH] fix: reduce code block highlight flicker Preserve layout with invisible placeholders while worker highlighting loads Reuse cached highlighted lines on first render when available Differentiate loading from failed highlighting results for better fallback behavior --- .../ui/src/components/chat/DiffPreview.tsx | 40 +++++++++----- .../chat/markdown/markdown-worker.ts | 6 +++ .../message/parts/VirtualizedCodeBlock.tsx | 3 +- .../code/useWorkerHighlightedLines.ts | 54 +++++++++++++++---- 4 files changed, 80 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/components/chat/DiffPreview.tsx b/packages/ui/src/components/chat/DiffPreview.tsx index c749b033..2c0fc05a 100644 --- a/packages/ui/src/components/chat/DiffPreview.tsx +++ b/packages/ui/src/components/chat/DiffPreview.tsx @@ -3,17 +3,29 @@ import { cn } from '@/lib/utils'; import { getLanguageFromExtension } from '@/lib/toolHelpers'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars'; -import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines'; +import { + useWorkerHighlightedLines, + type WorkerHighlightedLinesResult, +} from '@/components/code/useWorkerHighlightedLines'; import { parseDiffToUnified } from './message/toolRenderers'; -// One highlighted line: swaps in worker-tokenized inner HTML when ready, falls -// back to plain text while loading or on failure. -const CodeLineContent: React.FC<{ content: string; html: string | undefined }> = ({ content, html }) => - html !== undefined ? ( - - ) : ( - {content} - ); +// Keep the line's layout stable while a cold worker request finishes. Plain +// text appears only if highlighting fails, avoiding a visible color flash. +interface CodeLineContentProps { + content: string; + html: string | undefined; + status: WorkerHighlightedLinesResult['status']; +} + +const CodeLineContent: React.FC = ({ content, html, status }) => { + if (status === 'ready' && html !== undefined) { + return ; + } + if (status === 'loading') { + return {content}; + } + return {content}; +}; interface DiffPreviewProps { diff: string; @@ -44,7 +56,7 @@ export const DiffPreview: React.FC = ({ diff, filePath }) => {
{hunk.lines.map((line, lineIdx) => { - const html = highlighted?.[lineCursor]; + const html = highlighted.lines?.[lineCursor]; lineCursor += 1; return (
= ({ diff, filePath }) => { {line.lineNumber || ''}
- +
); @@ -106,7 +118,11 @@ export const WritePreview: React.FC = ({ content, filePath }) {lineIdx + 1}
- +
))} diff --git a/packages/ui/src/components/chat/markdown/markdown-worker.ts b/packages/ui/src/components/chat/markdown/markdown-worker.ts index ab5b6eb6..2feb270a 100644 --- a/packages/ui/src/components/chat/markdown/markdown-worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-worker.ts @@ -167,6 +167,12 @@ export const highlightLinesInWorker = async (code: string, lang: string): Promis return result?.type === 'highlightLines' ? result.lines : null; }; +/** Return an already-tokenized line result without scheduling a worker request. */ +export const getCachedHighlightedLines = (code: string, lang: string): string[] | null => { + const cached = resultCache.get(cacheKeyFor('highlightLines', lang, code)); + return cached?.type === 'highlightLines' ? cached.lines : null; +}; + /** * Tokenize `code` with the given resolved TextMate theme and return per-line * styled runs with offsets — for building CodeMirror decorations that match the diff --git a/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx b/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx index 5ef116d7..fcc29517 100644 --- a/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx +++ b/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx @@ -54,7 +54,8 @@ export const VirtualizedCodeBlock: React.FC = React.m const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); // Tokenize the whole block in one worker call; rows index into the result. const fullText = React.useMemo(() => lines.map((line) => line.text).join('\n'), [lines]); - const highlighted = useWorkerHighlightedLines(fullText, language); + const highlightResult = useWorkerHighlightedLines(fullText, language); + const highlighted = highlightResult.lines; const shouldVirtualize = lines.length > VIRTUALIZE_THRESHOLD; diff --git a/packages/ui/src/components/code/useWorkerHighlightedLines.ts b/packages/ui/src/components/code/useWorkerHighlightedLines.ts index 2a27e2ee..a9d20e60 100644 --- a/packages/ui/src/components/code/useWorkerHighlightedLines.ts +++ b/packages/ui/src/components/code/useWorkerHighlightedLines.ts @@ -1,26 +1,60 @@ import React from 'react'; -import { highlightLinesInWorker } from '@/components/chat/markdown/markdown-worker'; +import { + getCachedHighlightedLines, + highlightLinesInWorker, +} from '@/components/chat/markdown/markdown-worker'; // Tokenize a whole block ONCE in the Shiki worker and expose per-line inner // HTML. For per-line layouts (diffs, gutters, virtualization) that would -// otherwise spawn one highlighter per row. Returns `null` until the first -// result lands (or permanently on failure) — callers render plain text then. +// otherwise spawn one highlighter per row. Cached results are available on the +// first render; cold requests distinguish loading from permanent failure so +// callers can choose whether to reveal their plain-text fallback. // // Whole-block tokenization also restores cross-line syntax context (multi-line // strings / comments) that independent per-line highlighting loses. -export const useWorkerHighlightedLines = (code: string, language: string): string[] | null => { - const [lines, setLines] = React.useState(null); +export type WorkerHighlightedLinesResult = + | { status: 'loading'; lines: null } + | { status: 'ready'; lines: string[] } + | { status: 'failed'; lines: null }; + +type HighlightState = WorkerHighlightedLinesResult & { + code: string; + language: string; +}; + +const getHighlightState = (code: string, language: string): HighlightState => { + const lines = getCachedHighlightedLines(code, language); + return lines + ? { status: 'ready', lines, code, language } + : { status: 'loading', lines: null, code, language }; +}; + +export const useWorkerHighlightedLines = (code: string, language: string): WorkerHighlightedLinesResult => { + const normalizedLanguage = (language || 'text').toLowerCase(); + const [state, setState] = React.useState(() => getHighlightState(code, normalizedLanguage)); React.useEffect(() => { + const cached = getCachedHighlightedLines(code, normalizedLanguage); + if (cached) { + setState({ status: 'ready', lines: cached, code, language: normalizedLanguage }); + return; + } + + setState({ status: 'loading', lines: null, code, language: normalizedLanguage }); let active = true; - setLines(null); - void highlightLinesInWorker(code, (language || 'text').toLowerCase()).then((result) => { - if (active) setLines(result); + void highlightLinesInWorker(code, normalizedLanguage).then((lines) => { + if (!active) return; + setState(lines + ? { status: 'ready', lines, code, language: normalizedLanguage } + : { status: 'failed', lines: null, code, language: normalizedLanguage }); }); return () => { active = false; }; - }, [code, language]); + }, [code, normalizedLanguage]); - return lines; + if (state.code !== code || state.language !== normalizedLanguage) { + return getHighlightState(code, normalizedLanguage); + } + return state; };