fix(ui): prevent memory leak in diff view with many modified files

- Add LRU eviction to diffCache (max 30 entries / 20MB)
- Single-file mode: render only selected diff, not all hidden ones
- Stacked mode: collapse files beyond first 10 by default
- Limit DiffWorkerProvider warmup to 10 files
- Reduce worker pool size (4 to 2) and AST cache (200 to 50)
This commit is contained in:
Bohdan Triapitsyn
2026-01-28 12:29:01 +02:00
parent 8c6eb3c44d
commit 91e8b9e1f5
3 changed files with 82 additions and 34 deletions
+25 -29
View File
@@ -29,6 +29,9 @@ import { useDeviceInfo } from '@/lib/device';
const SIDE_BY_SIDE_MIN_WIDTH = 1100;
const DIFF_REQUEST_TIMEOUT_MS = 15000;
// Memory optimization: limit concurrent expanded diffs in stacked view
const STACKED_VIEW_MAX_EXPANDED_DIFFS = 10;
type FileEntry = GitStatus['files'][number] & {
insertions: number;
deletions: number;
@@ -453,7 +456,7 @@ const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
);
});
// Single diff viewer instance - stays mounted
// Single diff viewer instance
interface SingleDiffViewerProps {
filePath: string;
diff: DiffData;
@@ -474,6 +477,11 @@ const SingleDiffViewer = React.memo<SingleDiffViewerProps>(({
[filePath]
);
// Don't render if not visible (memory optimization)
if (!isVisible) {
return null;
}
// Check if this is an image file
if (isImageFile(filePath)) {
return (
@@ -486,23 +494,6 @@ const SingleDiffViewer = React.memo<SingleDiffViewerProps>(({
);
}
// Use display:none for hidden diffs to exclude from layout calculations during resize
// This is faster for resize than visibility:hidden which keeps elements in layout flow
if (!isVisible) {
return (
<div className="absolute inset-0 hidden">
<PierreDiffViewer
original={diff.original}
modified={diff.modified}
language={language}
fileName={filePath}
renderSideBySide={renderSideBySide}
wrapLines={wrapLines}
/>
</div>
);
}
return (
<div className="absolute inset-0" style={{ contain: 'size layout' }}>
<PierreDiffViewer
@@ -565,6 +556,8 @@ interface MultiFileDiffEntryProps {
isSelected: boolean;
onSelect: (path: string) => void;
registerSectionRef: (path: string, node: HTMLDivElement | null) => void;
/** Start collapsed to reduce memory with many files */
defaultCollapsed?: boolean;
}
const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
@@ -576,6 +569,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
isSelected,
onSelect,
registerSectionRef,
defaultCollapsed = false,
}) => {
const { git } = useRuntimeAPIs();
const cachedDiff = useGitStore(
@@ -586,7 +580,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
const setDiff = useGitStore((state) => state.setDiff);
const setDiffFileLayout = useUIStore((state) => state.setDiffFileLayout);
const [isExpanded, setIsExpanded] = React.useState(true);
const [isExpanded, setIsExpanded] = React.useState(!defaultCollapsed);
const [hasBeenVisible, setHasBeenVisible] = React.useState(false);
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
@@ -947,6 +941,7 @@ export const DiffView: React.FC = () => {
const handleSelectFileAndScroll = React.useCallback((value: string) => {
setSelectedFile(value);
if (isStackedView && !scrollToFile(value)) {
pendingScrollTargetRef.current = value;
}
@@ -1040,20 +1035,20 @@ export const DiffView: React.FC = () => {
};
}, [effectiveDirectory, isStackedView, selectedFile, selectedCachedDiff, git, setDiff, diffRetryNonce]);
// Render all diff viewers - they stay mounted
const renderAllDiffViewers = () => {
if (!effectiveDirectory || changedFiles.length === 0) return null;
// Render only the selected diff viewer to prevent memory bloat with many files
const renderSelectedDiffViewer = () => {
if (!effectiveDirectory || !selectedFile) return null;
return changedFiles.map((file) => (
return (
<DiffViewerEntry
key={file.path}
key={selectedFile}
directory={effectiveDirectory}
filePath={file.path}
isVisible={file.path === selectedFile}
filePath={selectedFile}
isVisible={true}
renderSideBySide={renderSideBySide}
wrapLines={diffWrapLines}
/>
));
);
};
const renderStackedDiffView = () => {
@@ -1081,7 +1076,7 @@ export const DiffView: React.FC = () => {
disableHorizontal
>
<div className="flex flex-col gap-3">
{changedFiles.map((file) => (
{changedFiles.map((file, index) => (
<MultiFileDiffEntry
key={file.path}
directory={effectiveDirectory}
@@ -1092,6 +1087,7 @@ export const DiffView: React.FC = () => {
isSelected={file.path === selectedFile}
onSelect={handleSelectFile}
registerSectionRef={registerSectionRef}
defaultCollapsed={index >= STACKED_VIEW_MAX_EXPANDED_DIFFS}
/>
))}
</div>
@@ -1141,7 +1137,7 @@ export const DiffView: React.FC = () => {
return (
<div className="flex flex-1 min-h-0 overflow-hidden px-3 py-3 relative">
{renderAllDiffViewers()}
{renderSelectedDiffViewer()}
{isCurrentFileLoading && !hasCurrentDiff && (
<div className="absolute inset-0 flex items-center justify-center gap-2 text-sm text-muted-foreground">
{diffLoadError ? (