Skip to content

Commit 020a17b

Browse files
authored
🤖 perf: fix diff 'Processing' flash with module-level cache (#1150)
## Problem Diffs frequently displayed "Processing..." even for previously-viewed content, causing visual flicker when scrolling through messages. ## Root Cause 1. `useHighlightedDiff` initialized state as `null` - even cached content required async operations 2. The existing cache in `highlightWorkerClient` was async (required hash lookup via `getCacheKey`) ## Solution - Add module-level LRU cache (`highlightedDiffCache`) storing full `HighlightedChunk[]` results - Synchronous cache lookup during render: `cachedResult ?? chunks` returns immediately - Remove redundant async cache from `highlightWorkerClient` (caching now at higher level) - Preserve `hasRealHighlightRef` to prevent downgrade when `enableHighlighting` toggles off (viewport optimization) --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_
1 parent bf64a1c commit 020a17b

File tree

2 files changed

+79
-61
lines changed

2 files changed

+79
-61
lines changed

src/browser/components/shared/DiffRenderer.tsx

Lines changed: 72 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
highlightDiffChunk,
1616
type HighlightedChunk,
1717
} from "@/browser/utils/highlighting/highlightDiffChunk";
18+
import { LRUCache } from "lru-cache";
1819
import {
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
*/
324363
function 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
/**

src/browser/utils/highlighting/highlightWorkerClient.ts

Lines changed: 7 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,19 @@
11
/**
2-
* Syntax highlighting client with LRU caching
2+
* Syntax highlighting client
33
*
44
* Provides async API for off-main-thread syntax highlighting via Web Worker.
5-
* Results are cached to avoid redundant highlighting of identical code.
6-
*
75
* Falls back to main-thread highlighting in test environments where
86
* Web Workers aren't available.
7+
*
8+
* Note: Caching happens at the caller level (DiffRenderer's highlightedDiffCache)
9+
* to enable synchronous cache hits and avoid "Processing" flash.
910
*/
1011

11-
import { LRUCache } from "lru-cache";
1212
import * as Comlink from "comlink";
1313
import type { Highlighter } from "shiki";
1414
import type { HighlightWorkerAPI } from "@/browser/workers/highlightWorker";
1515
import { mapToShikiLang, SHIKI_DARK_THEME, SHIKI_LIGHT_THEME } from "./shiki-shared";
1616

17-
// ─────────────────────────────────────────────────────────────────────────────
18-
// LRU Cache with SHA-256 hashing
19-
// ─────────────────────────────────────────────────────────────────────────────
20-
21-
/**
22-
* Cache for highlighted HTML results
23-
* Key: First 64 bits of SHA-256 hash (hex string)
24-
* Value: Shiki HTML output
25-
*/
26-
const highlightCache = new LRUCache<string, string>({
27-
max: 10000, // High limit — rely on maxSize for eviction
28-
maxSize: 8 * 1024 * 1024, // 8MB total
29-
sizeCalculation: (html) => html.length * 2, // Rough bytes for JS strings
30-
});
31-
32-
async function getCacheKey(code: string, language: string, theme: string): Promise<string> {
33-
const { hashKey } = await import("@/common/lib/hashKey");
34-
return hashKey(`${language}:${theme}:${code}`);
35-
}
36-
3717
// ─────────────────────────────────────────────────────────────────────────────
3818
// Main-thread Shiki (fallback only)
3919
// ─────────────────────────────────────────────────────────────────────────────
@@ -133,9 +113,8 @@ async function highlightMainThread(
133113
// ─────────────────────────────────────────────────────────────────────────────
134114

135115
/**
136-
* Highlight code with syntax highlighting (cached, off-main-thread)
116+
* Highlight code with syntax highlighting (off-main-thread)
137117
*
138-
* Results are cached by (code, language, theme) to avoid redundant work.
139118
* Highlighting runs in a Web Worker to avoid blocking the main thread.
140119
*
141120
* @param code - Source code to highlight
@@ -149,22 +128,9 @@ export async function highlightCode(
149128
language: string,
150129
theme: "dark" | "light"
151130
): Promise<string> {
152-
// Check cache first
153-
const cacheKey = await getCacheKey(code, language, theme);
154-
const cached = highlightCache.get(cacheKey);
155-
if (cached) return cached;
156-
157-
// Dispatch to worker or main-thread fallback
158131
const api = getWorkerAPI();
159-
let html: string;
160-
161132
if (!api) {
162-
html = await highlightMainThread(code, language, theme);
163-
} else {
164-
html = await api.highlight(code, language, theme);
133+
return highlightMainThread(code, language, theme);
165134
}
166-
167-
// Cache result
168-
highlightCache.set(cacheKey, html);
169-
return html;
135+
return api.highlight(code, language, theme);
170136
}

0 commit comments

Comments
 (0)