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
This commit is contained in:
@@ -3,17 +3,29 @@ import { cn } from '@/lib/utils';
|
|||||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars';
|
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';
|
import { parseDiffToUnified } from './message/toolRenderers';
|
||||||
|
|
||||||
// One highlighted line: swaps in worker-tokenized inner HTML when ready, falls
|
// Keep the line's layout stable while a cold worker request finishes. Plain
|
||||||
// back to plain text while loading or on failure.
|
// text appears only if highlighting fails, avoiding a visible color flash.
|
||||||
const CodeLineContent: React.FC<{ content: string; html: string | undefined }> = ({ content, html }) =>
|
interface CodeLineContentProps {
|
||||||
html !== undefined ? (
|
content: string;
|
||||||
<span className="whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: html }} />
|
html: string | undefined;
|
||||||
) : (
|
status: WorkerHighlightedLinesResult['status'];
|
||||||
<span className="whitespace-pre-wrap break-all">{content}</span>
|
}
|
||||||
);
|
|
||||||
|
const CodeLineContent: React.FC<CodeLineContentProps> = ({ content, html, status }) => {
|
||||||
|
if (status === 'ready' && html !== undefined) {
|
||||||
|
return <span className="whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||||
|
}
|
||||||
|
if (status === 'loading') {
|
||||||
|
return <span aria-hidden className="invisible whitespace-pre-wrap break-all">{content}</span>;
|
||||||
|
}
|
||||||
|
return <span className="whitespace-pre-wrap break-all">{content}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
interface DiffPreviewProps {
|
interface DiffPreviewProps {
|
||||||
diff: string;
|
diff: string;
|
||||||
@@ -44,7 +56,7 @@ export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, filePath }) => {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
{hunk.lines.map((line, lineIdx) => {
|
{hunk.lines.map((line, lineIdx) => {
|
||||||
const html = highlighted?.[lineCursor];
|
const html = highlighted.lines?.[lineCursor];
|
||||||
lineCursor += 1;
|
lineCursor += 1;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -67,7 +79,7 @@ export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, filePath }) => {
|
|||||||
{line.lineNumber || ''}
|
{line.lineNumber || ''}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<CodeLineContent content={line.content} html={html} />
|
<CodeLineContent content={line.content} html={html} status={highlighted.status} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -106,7 +118,11 @@ export const WritePreview: React.FC<WritePreviewProps> = ({ content, filePath })
|
|||||||
{lineIdx + 1}
|
{lineIdx + 1}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<CodeLineContent content={line || ' '} html={highlighted?.[lineIdx]} />
|
<CodeLineContent
|
||||||
|
content={line || ' '}
|
||||||
|
html={highlighted.lines?.[lineIdx]}
|
||||||
|
status={highlighted.status}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -167,6 +167,12 @@ export const highlightLinesInWorker = async (code: string, lang: string): Promis
|
|||||||
return result?.type === 'highlightLines' ? result.lines : null;
|
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
|
* Tokenize `code` with the given resolved TextMate theme and return per-line
|
||||||
* styled runs with offsets — for building CodeMirror decorations that match the
|
* styled runs with offsets — for building CodeMirror decorations that match the
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ export const VirtualizedCodeBlock: React.FC<VirtualizedCodeBlockProps> = React.m
|
|||||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||||
// Tokenize the whole block in one worker call; rows index into the result.
|
// 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 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;
|
const shouldVirtualize = lines.length > VIRTUALIZE_THRESHOLD;
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,60 @@
|
|||||||
import React from 'react';
|
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
|
// Tokenize a whole block ONCE in the Shiki worker and expose per-line inner
|
||||||
// HTML. For per-line layouts (diffs, gutters, virtualization) that would
|
// HTML. For per-line layouts (diffs, gutters, virtualization) that would
|
||||||
// otherwise spawn one highlighter per row. Returns `null` until the first
|
// otherwise spawn one highlighter per row. Cached results are available on the
|
||||||
// result lands (or permanently on failure) — callers render plain text then.
|
// 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
|
// Whole-block tokenization also restores cross-line syntax context (multi-line
|
||||||
// strings / comments) that independent per-line highlighting loses.
|
// strings / comments) that independent per-line highlighting loses.
|
||||||
export const useWorkerHighlightedLines = (code: string, language: string): string[] | null => {
|
export type WorkerHighlightedLinesResult =
|
||||||
const [lines, setLines] = React.useState<string[] | null>(null);
|
| { 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<HighlightState>(() => getHighlightState(code, normalizedLanguage));
|
||||||
|
|
||||||
React.useEffect(() => {
|
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;
|
let active = true;
|
||||||
setLines(null);
|
void highlightLinesInWorker(code, normalizedLanguage).then((lines) => {
|
||||||
void highlightLinesInWorker(code, (language || 'text').toLowerCase()).then((result) => {
|
if (!active) return;
|
||||||
if (active) setLines(result);
|
setState(lines
|
||||||
|
? { status: 'ready', lines, code, language: normalizedLanguage }
|
||||||
|
: { status: 'failed', lines: null, code, language: normalizedLanguage });
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [code, language]);
|
}, [code, normalizedLanguage]);
|
||||||
|
|
||||||
return lines;
|
if (state.code !== code || state.language !== normalizedLanguage) {
|
||||||
|
return getHighlightState(code, normalizedLanguage);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user