diff --git a/src/browser/components/shared/DiffRenderer.tsx b/src/browser/components/shared/DiffRenderer.tsx index cc807f7828..4066e16e88 100644 --- a/src/browser/components/shared/DiffRenderer.tsx +++ b/src/browser/components/shared/DiffRenderer.tsx @@ -15,6 +15,7 @@ import { highlightDiffChunk, type HighlightedChunk, } from "@/browser/utils/highlighting/highlightDiffChunk"; +import { LRUCache } from "lru-cache"; import { highlightSearchMatches, type SearchHighlightConfig, @@ -315,11 +316,49 @@ interface DiffRendererProps { } /** - * Hook to pre-process and highlight diff content in chunks - * Runs once when content/language changes (NOT search - that's applied post-process) + * Module-level cache for fully-highlighted diff results. + * Key: `${content.length}:${oldStart}:${newStart}:${language}:${themeMode}` + * (Using content.length instead of full content as a fast differentiator - collisions are rare + * and just cause re-highlighting, not incorrect rendering) * - * CACHING: Once highlighted with real language, result is cached even if enableHighlighting - * becomes false later. This prevents re-highlighting during scroll when hunks leave viewport. + * This allows synchronous cache hits, eliminating the "Processing" flash when + * re-rendering the same diff content (e.g., scrolling back to a previously-viewed message). + */ +const highlightedDiffCache = new LRUCache({ + max: 10000, // High limit - rely on maxSize for eviction + maxSize: 4 * 1024 * 1024, // 4MB total + sizeCalculation: (chunks) => + chunks.reduce( + (total, chunk) => + total + chunk.lines.reduce((lineTotal, line) => lineTotal + line.html.length * 2, 0), + 0 + ), +}); + +function getDiffCacheKey( + content: string, + language: string, + oldStart: number, + newStart: number, + themeMode: ThemeMode +): string { + // Use content hash for more reliable cache hits + // Simple hash: length + first/last 100 chars (fast, unique enough for this use case) + const contentHash = + content.length <= 200 + ? content + : `${content.length}:${content.slice(0, 100)}:${content.slice(-100)}`; + return `${contentHash}:${oldStart}:${newStart}:${language}:${themeMode}`; +} + +/** + * Hook to pre-process and highlight diff content in chunks. + * Results are cached at the module level for synchronous cache hits, + * eliminating "Processing" flash when re-rendering the same diff. + * + * When language="text" (highlighting disabled), keeps existing highlighted + * chunks rather than downgrading to plain text. This prevents flicker when + * hunks scroll out of viewport (enableHighlighting=false). */ function useHighlightedDiff( content: string, @@ -328,47 +367,60 @@ function useHighlightedDiff( newStart: number, themeMode: ThemeMode ): HighlightedChunk[] | null { - const [chunks, setChunks] = useState(null); - // Track if we've already highlighted with real syntax (to prevent downgrading) - const hasHighlightedRef = React.useRef(false); + const cacheKey = getDiffCacheKey(content, language, oldStart, newStart, themeMode); + const cachedResult = highlightedDiffCache.get(cacheKey); + + // State for async highlighting results (initialized from cache if available) + const [chunks, setChunks] = useState(cachedResult ?? null); + // Track if we've highlighted this content with real syntax (not plain text) + const hasRealHighlightRef = React.useRef(false); useEffect(() => { - // If already highlighted and trying to switch to plain text, keep the highlighted version - if (hasHighlightedRef.current && language === "text") { - return; // Keep cached highlighted chunks + // Already in cache - sync state and skip async work + const cached = highlightedDiffCache.get(cacheKey); + if (cached) { + setChunks(cached); + if (language !== "text") { + hasRealHighlightRef.current = true; + } + return; + } + + // When highlighting is disabled (language="text") but we've already + // highlighted with real syntax, keep showing that version + if (language === "text" && hasRealHighlightRef.current) { + return; } + // Reset to loading state for new uncached content + setChunks(null); + let cancelled = false; async function highlight() { - // Split into lines (preserve indices for selection + rendering) const lines = splitDiffLines(content); - - // Group into chunks const diffChunks = groupDiffLines(lines, oldStart, newStart); - - // Highlight each chunk (without search decorations - those are applied later) const highlighted = await Promise.all( diffChunks.map((chunk) => highlightDiffChunk(chunk, language, themeMode)) ); if (!cancelled) { + highlightedDiffCache.set(cacheKey, highlighted); setChunks(highlighted); - // Mark as highlighted if using real language (not plain text) if (language !== "text") { - hasHighlightedRef.current = true; + hasRealHighlightRef.current = true; } } } void highlight(); - return () => { cancelled = true; }; - }, [content, language, oldStart, newStart, themeMode]); + }, [cacheKey, content, language, oldStart, newStart, themeMode]); - return chunks; + // Return cached result directly if available (sync path), else state (async path) + return cachedResult ?? chunks; } /** diff --git a/src/browser/utils/highlighting/highlightWorkerClient.ts b/src/browser/utils/highlighting/highlightWorkerClient.ts index ca11a79cfe..cdb95bff15 100644 --- a/src/browser/utils/highlighting/highlightWorkerClient.ts +++ b/src/browser/utils/highlighting/highlightWorkerClient.ts @@ -1,39 +1,19 @@ /** - * Syntax highlighting client with LRU caching + * Syntax highlighting client * * Provides async API for off-main-thread syntax highlighting via Web Worker. - * Results are cached to avoid redundant highlighting of identical code. - * * Falls back to main-thread highlighting in test environments where * Web Workers aren't available. + * + * Note: Caching happens at the caller level (DiffRenderer's highlightedDiffCache) + * to enable synchronous cache hits and avoid "Processing" flash. */ -import { LRUCache } from "lru-cache"; import * as Comlink from "comlink"; import type { Highlighter } from "shiki"; import type { HighlightWorkerAPI } from "@/browser/workers/highlightWorker"; import { mapToShikiLang, SHIKI_DARK_THEME, SHIKI_LIGHT_THEME } from "./shiki-shared"; -// ───────────────────────────────────────────────────────────────────────────── -// LRU Cache with SHA-256 hashing -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Cache for highlighted HTML results - * Key: First 64 bits of SHA-256 hash (hex string) - * Value: Shiki HTML output - */ -const highlightCache = new LRUCache({ - max: 10000, // High limit — rely on maxSize for eviction - maxSize: 8 * 1024 * 1024, // 8MB total - sizeCalculation: (html) => html.length * 2, // Rough bytes for JS strings -}); - -async function getCacheKey(code: string, language: string, theme: string): Promise { - const { hashKey } = await import("@/common/lib/hashKey"); - return hashKey(`${language}:${theme}:${code}`); -} - // ───────────────────────────────────────────────────────────────────────────── // Main-thread Shiki (fallback only) // ───────────────────────────────────────────────────────────────────────────── @@ -133,9 +113,8 @@ async function highlightMainThread( // ───────────────────────────────────────────────────────────────────────────── /** - * Highlight code with syntax highlighting (cached, off-main-thread) + * Highlight code with syntax highlighting (off-main-thread) * - * Results are cached by (code, language, theme) to avoid redundant work. * Highlighting runs in a Web Worker to avoid blocking the main thread. * * @param code - Source code to highlight @@ -149,22 +128,9 @@ export async function highlightCode( language: string, theme: "dark" | "light" ): Promise { - // Check cache first - const cacheKey = await getCacheKey(code, language, theme); - const cached = highlightCache.get(cacheKey); - if (cached) return cached; - - // Dispatch to worker or main-thread fallback const api = getWorkerAPI(); - let html: string; - if (!api) { - html = await highlightMainThread(code, language, theme); - } else { - html = await api.highlight(code, language, theme); + return highlightMainThread(code, language, theme); } - - // Cache result - highlightCache.set(cacheKey, html); - return html; + return api.highlight(code, language, theme); }