@@ -15,6 +15,7 @@ import {
1515 highlightDiffChunk ,
1616 type HighlightedChunk ,
1717} from "@/browser/utils/highlighting/highlightDiffChunk" ;
18+ import { LRUCache } from "lru-cache" ;
1819import {
1920 highlightSearchMatches ,
2021 type SearchHighlightConfig ,
@@ -315,11 +316,49 @@ interface DiffRendererProps {
315316}
316317
317318/**
318- * Hook to pre-process and highlight diff content in chunks
319- * Runs once when content/language changes (NOT search - that's applied post-process)
319+ * Module-level cache for fully-highlighted diff results.
320+ * Key: `${content.length}:${oldStart}:${newStart}:${language}:${themeMode}`
321+ * (Using content.length instead of full content as a fast differentiator - collisions are rare
322+ * and just cause re-highlighting, not incorrect rendering)
320323 *
321- * CACHING: Once highlighted with real language, result is cached even if enableHighlighting
322- * becomes false later. This prevents re-highlighting during scroll when hunks leave viewport.
324+ * This allows synchronous cache hits, eliminating the "Processing" flash when
325+ * re-rendering the same diff content (e.g., scrolling back to a previously-viewed message).
326+ */
327+ const highlightedDiffCache = new LRUCache < string , HighlightedChunk [ ] > ( {
328+ max : 10000 , // High limit - rely on maxSize for eviction
329+ maxSize : 4 * 1024 * 1024 , // 4MB total
330+ sizeCalculation : ( chunks ) =>
331+ chunks . reduce (
332+ ( total , chunk ) =>
333+ total + chunk . lines . reduce ( ( lineTotal , line ) => lineTotal + line . html . length * 2 , 0 ) ,
334+ 0
335+ ) ,
336+ } ) ;
337+
338+ function getDiffCacheKey (
339+ content : string ,
340+ language : string ,
341+ oldStart : number ,
342+ newStart : number ,
343+ themeMode : ThemeMode
344+ ) : string {
345+ // Use content hash for more reliable cache hits
346+ // Simple hash: length + first/last 100 chars (fast, unique enough for this use case)
347+ const contentHash =
348+ content . length <= 200
349+ ? content
350+ : `${ content . length } :${ content . slice ( 0 , 100 ) } :${ content . slice ( - 100 ) } ` ;
351+ return `${ contentHash } :${ oldStart } :${ newStart } :${ language } :${ themeMode } ` ;
352+ }
353+
354+ /**
355+ * Hook to pre-process and highlight diff content in chunks.
356+ * Results are cached at the module level for synchronous cache hits,
357+ * eliminating "Processing" flash when re-rendering the same diff.
358+ *
359+ * When language="text" (highlighting disabled), keeps existing highlighted
360+ * chunks rather than downgrading to plain text. This prevents flicker when
361+ * hunks scroll out of viewport (enableHighlighting=false).
323362 */
324363function useHighlightedDiff (
325364 content : string ,
@@ -328,47 +367,60 @@ function useHighlightedDiff(
328367 newStart : number ,
329368 themeMode : ThemeMode
330369) : HighlightedChunk [ ] | null {
331- const [ chunks , setChunks ] = useState < HighlightedChunk [ ] | null > ( null ) ;
332- // Track if we've already highlighted with real syntax (to prevent downgrading)
333- const hasHighlightedRef = React . useRef ( false ) ;
370+ const cacheKey = getDiffCacheKey ( content , language , oldStart , newStart , themeMode ) ;
371+ const cachedResult = highlightedDiffCache . get ( cacheKey ) ;
372+
373+ // State for async highlighting results (initialized from cache if available)
374+ const [ chunks , setChunks ] = useState < HighlightedChunk [ ] | null > ( cachedResult ?? null ) ;
375+ // Track if we've highlighted this content with real syntax (not plain text)
376+ const hasRealHighlightRef = React . useRef ( false ) ;
334377
335378 useEffect ( ( ) => {
336- // If already highlighted and trying to switch to plain text, keep the highlighted version
337- if ( hasHighlightedRef . current && language === "text" ) {
338- return ; // Keep cached highlighted chunks
379+ // Already in cache - sync state and skip async work
380+ const cached = highlightedDiffCache . get ( cacheKey ) ;
381+ if ( cached ) {
382+ setChunks ( cached ) ;
383+ if ( language !== "text" ) {
384+ hasRealHighlightRef . current = true ;
385+ }
386+ return ;
387+ }
388+
389+ // When highlighting is disabled (language="text") but we've already
390+ // highlighted with real syntax, keep showing that version
391+ if ( language === "text" && hasRealHighlightRef . current ) {
392+ return ;
339393 }
340394
395+ // Reset to loading state for new uncached content
396+ setChunks ( null ) ;
397+
341398 let cancelled = false ;
342399
343400 async function highlight ( ) {
344- // Split into lines (preserve indices for selection + rendering)
345401 const lines = splitDiffLines ( content ) ;
346-
347- // Group into chunks
348402 const diffChunks = groupDiffLines ( lines , oldStart , newStart ) ;
349-
350- // Highlight each chunk (without search decorations - those are applied later)
351403 const highlighted = await Promise . all (
352404 diffChunks . map ( ( chunk ) => highlightDiffChunk ( chunk , language , themeMode ) )
353405 ) ;
354406
355407 if ( ! cancelled ) {
408+ highlightedDiffCache . set ( cacheKey , highlighted ) ;
356409 setChunks ( highlighted ) ;
357- // Mark as highlighted if using real language (not plain text)
358410 if ( language !== "text" ) {
359- hasHighlightedRef . current = true ;
411+ hasRealHighlightRef . current = true ;
360412 }
361413 }
362414 }
363415
364416 void highlight ( ) ;
365-
366417 return ( ) => {
367418 cancelled = true ;
368419 } ;
369- } , [ content , language , oldStart , newStart , themeMode ] ) ;
420+ } , [ cacheKey , content , language , oldStart , newStart , themeMode ] ) ;
370421
371- return chunks ;
422+ // Return cached result directly if available (sync path), else state (async path)
423+ return cachedResult ?? chunks ;
372424}
373425
374426/**
0 commit comments