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:
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user