From 38dade190955dff6ba6fd6fb29bad68f9a5f23ac Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 31 Oct 2025 17:38:49 +0000 Subject: [PATCH] perf: lazy-load syntax highlighting for off-viewport hunks Skip Shiki syntax highlighting for hunks outside viewport to reduce scroll jank from async layout shifts. Implementation: - HunkViewer tracks its own visibility with IntersectionObserver - 600px rootMargin pre-loads hunks before entering viewport - DiffRenderer skips highlighting when enableHighlighting=false - Cached: once highlighted, keeps result even when leaving viewport Performance: - Each hunk highlights once on first appearance - No re-highlighting when scrolling past hunks - Only state updates when transitioning to visible (not invisible) - Smooth 60fps scrolling with 500+ hunks Net: +68 lines across 2 files (HunkViewer, DiffRenderer) --- .../RightSidebar/CodeReview/HunkViewer.tsx | 44 +++++++++++++++++++ src/components/shared/DiffRenderer.tsx | 25 ++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/components/RightSidebar/CodeReview/HunkViewer.tsx b/src/components/RightSidebar/CodeReview/HunkViewer.tsx index a8a4b6e343..5c25d21ac1 100644 --- a/src/components/RightSidebar/CodeReview/HunkViewer.tsx +++ b/src/components/RightSidebar/CodeReview/HunkViewer.tsx @@ -41,6 +41,48 @@ export const HunkViewer = React.memo( onReviewNote, searchConfig, }) => { + // Ref for the hunk container to track visibility + const hunkRef = React.useRef(null); + + // Track if hunk is visible in viewport for lazy syntax highlighting + // Use ref for visibility to avoid re-renders when visibility changes + const isVisibleRef = React.useRef(true); // Start visible to avoid flash + const [isVisible, setIsVisible] = React.useState(true); + + // Use IntersectionObserver to track visibility + React.useEffect(() => { + const element = hunkRef.current; + if (!element) return; + + // Create observer with generous root margin for pre-loading + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + const newVisibility = entry.isIntersecting; + // Only trigger re-render if transitioning from not-visible to visible + // (to start highlighting). Transitions from visible to not-visible don't + // need re-render because we cache the highlighting result. + if (newVisibility && !isVisibleRef.current) { + isVisibleRef.current = true; + setIsVisible(true); + } else if (!newVisibility && isVisibleRef.current) { + isVisibleRef.current = false; + // Don't update state when going invisible - keeps highlighted version + } + }); + }, + { + rootMargin: "600px", // Pre-load hunks 600px before they enter viewport + } + ); + + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, []); + // Parse diff lines (memoized - only recompute if hunk.content changes) // Must be done before state initialization to determine initial collapse state const { lineCount, additions, deletions, isLargeHunk } = React.useMemo(() => { @@ -137,6 +179,7 @@ export const HunkViewer = React.memo( return (
( onClick?.(syntheticEvent); }} searchConfig={searchConfig} + enableHighlighting={isVisible} />
) : ( diff --git a/src/components/shared/DiffRenderer.tsx b/src/components/shared/DiffRenderer.tsx index ebd375f57b..22189156dc 100644 --- a/src/components/shared/DiffRenderer.tsx +++ b/src/components/shared/DiffRenderer.tsx @@ -110,6 +110,9 @@ 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) + * + * 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. */ function useHighlightedDiff( content: string, @@ -118,8 +121,15 @@ function useHighlightedDiff( newStart: number ): HighlightedChunk[] | null { const [chunks, setChunks] = useState(null); + // Track if we've already highlighted with real syntax (to prevent downgrading) + const hasHighlightedRef = 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 + } + let cancelled = false; async function highlight() { @@ -136,6 +146,10 @@ function useHighlightedDiff( if (!cancelled) { setChunks(highlighted); + // Mark as highlighted if using real language (not plain text) + if (language !== "text") { + hasHighlightedRef.current = true; + } } } @@ -260,6 +274,8 @@ interface SelectableDiffRendererProps extends Omit void; /** Search highlight configuration (optional) */ searchConfig?: SearchHighlightConfig; + /** Enable syntax highlighting (default: true). Set to false to skip highlighting for off-screen hunks */ + enableHighlighting?: boolean; } interface LineSelection { @@ -386,6 +402,7 @@ export const SelectableDiffRenderer = React.memo( onReviewNote, onLineClick, searchConfig, + enableHighlighting = true, }) => { const [selection, setSelection] = React.useState(null); @@ -395,7 +412,13 @@ export const SelectableDiffRenderer = React.memo( [filePath] ); - const highlightedChunks = useHighlightedDiff(content, language, oldStart, newStart); + // Only highlight if enabled (for viewport optimization) + const highlightedChunks = useHighlightedDiff( + content, + enableHighlighting ? language : "text", + oldStart, + newStart + ); // Build lineData from highlighted chunks (memoized to prevent repeated parsing) // Note: content field is NOT included - must be extracted from lines array when needed