feat: viewport virtualization for DiffViewer + lazy per-file hunk loading in FileCard

DiffViewer now uses IntersectionObserver to replace off-viewport FileCards with
48px placeholder divs, eliminating thousands of DOM nodes at initial render for
large diffs. Files within 1× viewport buffer are rendered as real FileCards.

FileCard defaults to collapsed state and only fires getFileDiff when expanded,
with staleTime: Infinity to avoid re-fetches. Handles loading/error/binary/
no-hunks states. Commit mode passes detail prop to skip lazy loading entirely.

DiffViewer batches expand-all in chunks of 10, prefetching via tRPC utils to
saturate the network without blocking the UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas May
2026-03-06 20:13:01 +01:00
parent 5968a6ba88
commit f804cb197c
3 changed files with 280 additions and 61 deletions

View File

@@ -1,5 +1,8 @@
import type { FileDiffDetail, DiffLine, ReviewComment } from "./types";
import { useCallback, useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { FileDiff, FileDiffDetail, DiffLine, ReviewComment } from "./types";
import { FileCard } from "./FileCard";
import { trpc } from "@/lib/trpc";
function getFileCommentMap(
commentsByLine: Map<string, ReviewComment[]>,
@@ -13,7 +16,9 @@ function getFileCommentMap(
}
interface DiffViewerProps {
files: FileDiffDetail[];
files: (FileDiff | FileDiffDetail)[];
phaseId: string;
commitMode: boolean;
commentsByLine: Map<string, ReviewComment[]>;
onAddComment: (
filePath: string,
@@ -28,10 +33,13 @@ interface DiffViewerProps {
viewedFiles?: Set<string>;
onToggleViewed?: (filePath: string) => void;
onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void;
expandAll?: boolean;
}
export function DiffViewer({
files,
phaseId,
commitMode,
commentsByLine,
onAddComment,
onResolveComment,
@@ -41,24 +49,155 @@ export function DiffViewer({
viewedFiles,
onToggleViewed,
onRegisterRef,
expandAll,
}: DiffViewerProps) {
// Set of file paths currently intersecting (or near) the viewport
const visibleFiles = useRef<Set<string>>(new Set());
// Map from filePath → wrapper div ref
const wrapperRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Increment to trigger re-render when visibility changes
const [visibilityVersion, setVisibilityVersion] = useState(0);
// Single IntersectionObserver for all wrappers
const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
if (files.length === 1) return; // skip for single file
observerRef.current = new IntersectionObserver(
(entries) => {
let changed = false;
for (const entry of entries) {
const filePath = (entry.target as HTMLDivElement).dataset['filePath'];
if (!filePath) continue;
if (entry.isIntersecting) {
if (!visibleFiles.current.has(filePath)) {
visibleFiles.current.add(filePath);
changed = true;
}
} else {
if (visibleFiles.current.has(filePath)) {
visibleFiles.current.delete(filePath);
changed = true;
}
}
}
if (changed) setVisibilityVersion((v) => v + 1);
},
{ rootMargin: '100% 0px 100% 0px' }, // 1× viewport above and below
);
// Observe all current wrapper divs
for (const el of wrapperRefs.current.values()) {
observerRef.current.observe(el);
}
return () => {
observerRef.current?.disconnect();
};
}, [files]); // re-create observer when file list changes
// Register wrapper ref — observes the div, registers with parent
const registerWrapper = useCallback(
(filePath: string, el: HTMLDivElement | null) => {
if (el) {
wrapperRefs.current.set(filePath, el);
observerRef.current?.observe(el);
} else {
const prev = wrapperRefs.current.get(filePath);
if (prev) observerRef.current?.unobserve(prev);
wrapperRefs.current.delete(filePath);
}
onRegisterRef?.(filePath, el);
},
[onRegisterRef],
);
// expandAll batch loading
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const queryClient = useQueryClient();
const utils = trpc.useUtils();
useEffect(() => {
if (!expandAll || files.length === 0) return;
const BATCH = 10;
let cancelled = false;
async function batchExpand() {
const chunks: (FileDiff | FileDiffDetail)[][] = [];
for (let i = 0; i < files.length; i += BATCH) {
chunks.push(files.slice(i, i + BATCH));
}
for (const chunk of chunks) {
if (cancelled) break;
// Mark this batch as expanded (triggers FileCard renders + queries)
setExpandedFiles((prev) => {
const next = new Set(prev);
for (const f of chunk) {
if (f.status !== 'binary') next.add(f.newPath);
}
return next;
});
// Eagerly prefetch via React Query to saturate network
await Promise.all(
chunk
.filter((f) => f.status !== 'binary' && !('hunks' in f))
.map((f) =>
utils.getFileDiff
.fetch({ phaseId, filePath: encodeURIComponent(f.newPath) })
.catch(() => null), // swallow per-file errors; FileCard shows its own error state
),
);
}
}
batchExpand();
return () => {
cancelled = true;
};
}, [expandAll]); // only re-run when expandAll toggles
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only on expandAll
// Suppress unused variable warning — used only to force re-render on visibility change
void visibilityVersion;
const isSingleFile = files.length === 1;
return (
<div className="space-y-4">
{files.map((file) => (
<div key={file.newPath} ref={(el) => onRegisterRef?.(file.newPath, el)}>
<FileCard
file={file}
commentsByLine={getFileCommentMap(commentsByLine, file.newPath)}
onAddComment={onAddComment}
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
onReplyComment={onReplyComment}
onEditComment={onEditComment}
isViewed={viewedFiles?.has(file.newPath) ?? false}
onToggleViewed={() => onToggleViewed?.(file.newPath)}
/>
</div>
))}
{files.map((file) => {
const isVisible = isSingleFile || visibleFiles.current.has(file.newPath);
const isExpandedOverride = expandedFiles.has(file.newPath) ? true : undefined;
return (
<div
key={file.newPath}
ref={(el) => registerWrapper(file.newPath, el)}
data-file-path={file.newPath}
>
{isVisible ? (
<FileCard
file={file as FileDiff}
detail={'hunks' in file ? (file as FileDiffDetail) : undefined}
phaseId={phaseId}
commitMode={commitMode}
commentsByLine={getFileCommentMap(commentsByLine, file.newPath)}
isExpandedOverride={isExpandedOverride}
onAddComment={onAddComment}
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
onReplyComment={onReplyComment}
onEditComment={onEditComment}
isViewed={viewedFiles?.has(file.newPath) ?? false}
onToggleViewed={() => onToggleViewed?.(file.newPath)}
/>
) : (
<div style={{ height: '48px' }} aria-hidden />
)}
</div>
);
})}
</div>
);
}

View File

@@ -6,16 +6,16 @@ import {
Minus,
CheckCircle2,
Circle,
Loader2,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import type { FileDiffDetail, DiffLine, ReviewComment } from "./types";
import type { FileDiff, FileDiffDetail, DiffLine, ReviewComment } from "./types";
import { HunkRows } from "./HunkRows";
import { useHighlightedFile } from "./use-syntax-highlight";
import { parseUnifiedDiff } from "./parse-diff";
import { trpc } from "@/lib/trpc";
const changeTypeBadge: Record<
FileDiffDetail['status'],
{ label: string; classes: string } | null
> = {
const statusBadge: Record<FileDiff['status'], { label: string; classes: string } | null> = {
added: {
label: "NEW",
classes:
@@ -32,10 +32,13 @@ const changeTypeBadge: Record<
"bg-status-active-bg text-status-active-fg border-status-active-border",
},
modified: null,
binary: null,
binary: {
label: "BINARY",
classes: "bg-muted text-muted-foreground border-border",
},
};
const leftBorderClass: Record<FileDiffDetail['status'], string> = {
const leftBorderClass: Record<FileDiff['status'], string> = {
added: "border-l-2 border-l-status-success-fg",
deleted: "border-l-2 border-l-status-error-fg",
renamed: "border-l-2 border-l-status-active-fg",
@@ -44,8 +47,12 @@ const leftBorderClass: Record<FileDiffDetail['status'], string> = {
};
interface FileCardProps {
file: FileDiffDetail;
file: FileDiff;
detail?: FileDiffDetail;
phaseId: string;
commitMode: boolean;
commentsByLine: Map<string, ReviewComment[]>;
isExpandedOverride?: boolean;
onAddComment: (
filePath: string,
lineNumber: number,
@@ -62,7 +69,11 @@ interface FileCardProps {
export function FileCard({
file,
detail,
phaseId,
commitMode,
commentsByLine,
isExpandedOverride,
onAddComment,
onResolveComment,
onUnresolveComment,
@@ -71,35 +82,65 @@ export function FileCard({
isViewed = false,
onToggleViewed = () => {},
}: FileCardProps) {
const [expanded, setExpanded] = useState(true);
// Uncontrolled expand for normal file clicks.
// Start expanded if detail prop is provided (commit mode).
const [isExpandedLocal, setIsExpandedLocal] = useState(() => !!detail);
const commentCount = useMemo(
() =>
Array.from(commentsByLine.values()).reduce(
(sum, arr) => sum + arr.length,
0,
),
[commentsByLine],
// Merge with override from DiffViewer expandAll
const isExpanded = isExpandedOverride ?? isExpandedLocal;
const fileDiffQuery = trpc.getFileDiff.useQuery(
{ phaseId, filePath: encodeURIComponent(file.newPath) },
{
enabled: isExpanded && !commitMode && file.status !== 'binary' && !detail,
staleTime: Infinity,
},
);
const badge = changeTypeBadge[file.status];
// Compute hunks from query data (phase mode)
const parsedHunks = useMemo(() => {
if (!fileDiffQuery.data?.rawDiff) return null;
const parsed = parseUnifiedDiff(fileDiffQuery.data.rawDiff);
return parsed[0] ?? null;
}, [fileDiffQuery.data]);
// Collect all lines for syntax highlighting
const allLines = useMemo(() => {
if (detail) return detail.hunks.flatMap((h) => h.lines);
if (parsedHunks) return parsedHunks.hunks.flatMap((h) => h.lines);
return [];
}, [detail, parsedHunks]);
// Flatten all hunk lines for syntax highlighting
const allLines = useMemo(
() => file.hunks.flatMap((h) => h.lines),
[file.hunks],
);
const tokenMap = useHighlightedFile(file.newPath, allLines);
const commentCount = useMemo(() => {
let count = 0;
for (const [key, arr] of commentsByLine) {
if (key.startsWith(`${file.newPath}:`)) count += arr.length;
}
return count;
}, [commentsByLine, file.newPath]);
const badge = statusBadge[file.status];
const handlers = {
onAddComment,
onResolveComment,
onUnresolveComment,
onReplyComment,
onEditComment,
tokenMap,
};
return (
<div className="rounded-lg border border-border overflow-clip">
{/* File header — sticky so it stays visible when scrolling */}
{/* File header */}
<button
className={`sticky z-10 flex w-full items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/90 text-left text-sm font-mono transition-colors ${leftBorderClass[file.status]}`}
style={{ top: 'var(--review-header-h, 0px)' }}
onClick={() => setExpanded(!expanded)}
onClick={() => setIsExpandedLocal(!isExpandedLocal)}
>
{expanded ? (
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@@ -160,26 +201,63 @@ export function FileCard({
</button>
{/* Diff content */}
{expanded && (
{isExpanded && (
<div className="overflow-x-auto">
<table className="w-full text-xs font-mono border-collapse">
<tbody>
{file.hunks.map((hunk, hi) => (
<HunkRows
key={hi}
hunk={hunk}
filePath={file.newPath}
commentsByLine={commentsByLine}
onAddComment={onAddComment}
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
onReplyComment={onReplyComment}
onEditComment={onEditComment}
tokenMap={tokenMap}
/>
))}
</tbody>
</table>
{detail ? (
// Commit mode: pre-parsed hunks from detail prop
detail.hunks.length === 0 ? (
<div className="px-4 py-3 text-xs text-muted-foreground">No content changes</div>
) : (
<table className="w-full text-xs font-mono border-collapse">
<tbody>
{detail.hunks.map((hunk, hi) => (
<HunkRows
key={hi}
hunk={hunk}
filePath={file.newPath}
commentsByLine={commentsByLine}
{...handlers}
/>
))}
</tbody>
</table>
)
) : file.status === 'binary' ? (
<div className="px-4 py-3 text-xs text-muted-foreground">Binary file diff not shown</div>
) : fileDiffQuery.isLoading ? (
<div className="flex items-center gap-2 px-4 py-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading diff
</div>
) : fileDiffQuery.isError ? (
<div className="flex items-center gap-2 px-4 py-3 text-xs text-destructive">
Failed to load diff.
<button
className="underline hover:no-underline"
onClick={() => fileDiffQuery.refetch()}
>
Retry
</button>
</div>
) : fileDiffQuery.data ? (
!parsedHunks || parsedHunks.hunks.length === 0 ? (
<div className="px-4 py-3 text-xs text-muted-foreground">No content changes</div>
) : (
<table className="w-full text-xs font-mono border-collapse">
<tbody>
{parsedHunks.hunks.map((hunk, hi) => (
<HunkRows
key={hi}
hunk={hunk}
filePath={file.newPath}
commentsByLine={commentsByLine}
{...handlers}
/>
))}
</tbody>
</table>
)
) : null}
</div>
)}
</div>

View File

@@ -406,6 +406,8 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
) : (
<DiffViewer
files={files}
phaseId={activePhaseId!}
commitMode={!!selectedCommit}
commentsByLine={commentsByLine}
onAddComment={handleAddComment}
onResolveComment={handleResolveComment}