Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,117 @@ describe('UploadInputV3', () => {
});
});

describe('Image Preview', () => {
beforeEach(() => {
URL.createObjectURL = jest.fn(file => `blob:${file.name}`);
URL.revokeObjectURL = jest.fn();
});

afterEach(() => {
delete URL.createObjectURL;
delete URL.revokeObjectURL;
});

test('shows preview immediately when an image file is added', () => {
render(<UploadInputV3 {...defaultProps} />);
act(() => {
dropzoneCallbacks.onAddedFile({ name: 'photo.jpg', size: 136000, type: 'image/jpeg' });
});
const img = screen.getByRole('img', { name: 'photo.jpg' });
expect(img).toHaveAttribute('src', 'blob:photo.jpg');
});

test('shows no preview for non-image files', () => {
render(<UploadInputV3 {...defaultProps} />);
act(() => {
dropzoneCallbacks.onAddedFile({ name: 'document.pdf', size: 50000, type: 'application/pdf' });
});
expect(screen.queryByRole('img', { name: 'document.pdf' })).not.toBeInTheDocument();
});

test('preserves blob URL preview after value updates with server-renamed filename', () => {
const { rerender } = render(<UploadInputV3 {...defaultProps} value={[]} />);

act(() => {
dropzoneCallbacks.onAddedFile({ name: 'photo.jpg', size: 136000, type: 'image/jpeg' });
dropzoneCallbacks.onFileCompleted({ name: 'photo.jpg', size: 136000 });
dropzoneCallbacks.onUploadComplete({ name: 'server_photo_abc123.jpg', size: 136000 }, 'test-upload', {});
});

rerender(<UploadInputV3 {...defaultProps} value={[{ filename: 'server_photo_abc123.jpg', size: 136000 }]} />);

const img = screen.getByRole('img', { name: 'server_photo_abc123.jpg' });
expect(img).toHaveAttribute('src', 'blob:photo.jpg');
});

test('revokes blob URL on cancel and does not assign it to the next upload', () => {
const { rerender } = render(<UploadInputV3 {...defaultProps} value={[]} maxFiles={2} />);

act(() => {
dropzoneCallbacks.onAddedFile({ name: 'photo-a.jpg', size: 10000, type: 'image/jpeg' });
});
act(() => { fireEvent.click(screen.getByRole('button')); });
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:photo-a.jpg');

act(() => {
dropzoneCallbacks.onAddedFile({ name: 'photo-b.jpg', size: 20000, type: 'image/jpeg' });
dropzoneCallbacks.onFileCompleted({ name: 'photo-b.jpg', size: 20000 });
dropzoneCallbacks.onUploadComplete({ name: 'server_photo-b_xyz.jpg', size: 20000 }, 'test-upload', {});
});

rerender(<UploadInputV3 {...defaultProps} value={[{ filename: 'server_photo-b_xyz.jpg', size: 20000 }]} maxFiles={2} />);

const img = screen.getByRole('img', { name: 'server_photo-b_xyz.jpg' });
expect(img).toHaveAttribute('src', 'blob:photo-b.jpg');
expect(img).not.toHaveAttribute('src', 'blob:photo-a.jpg');
});

test('correctly maps previews for parallel uploads using response size', () => {
const { rerender } = render(<UploadInputV3 {...defaultProps} value={[]} maxFiles={2} />);

act(() => {
dropzoneCallbacks.onAddedFile({ name: 'sunset.jpg', size: 10000, type: 'image/jpeg' });
dropzoneCallbacks.onAddedFile({ name: 'portrait.jpg', size: 20000, type: 'image/jpeg' });
dropzoneCallbacks.onFileCompleted({ name: 'sunset.jpg', size: 10000 });
dropzoneCallbacks.onFileCompleted({ name: 'portrait.jpg', size: 20000 });
// server returns files in reverse order
dropzoneCallbacks.onUploadComplete({ name: '246_portrait_abc123.jpg', size: 20000 }, 'test-upload', {});
dropzoneCallbacks.onUploadComplete({ name: '246_sunset_def456.jpg', size: 10000 }, 'test-upload', {});
});

rerender(<UploadInputV3 {...defaultProps} maxFiles={2} value={[
{ filename: '246_portrait_abc123.jpg', size: 20000 },
{ filename: '246_sunset_def456.jpg', size: 10000 },
]} />);

expect(screen.getByRole('img', { name: '246_portrait_abc123.jpg' })).toHaveAttribute('src', 'blob:portrait.jpg');
expect(screen.getByRole('img', { name: '246_sunset_def456.jpg' })).toHaveAttribute('src', 'blob:sunset.jpg');
});

test('revokes blob URL on error and does not assign it to the next upload', () => {
const { rerender } = render(<UploadInputV3 {...defaultProps} value={[]} maxFiles={2} />);

act(() => {
dropzoneCallbacks.onAddedFile({ name: 'photo-a.jpg', size: 10000, type: 'image/jpeg' });
dropzoneCallbacks.onFileError({ name: 'photo-a.jpg', size: 10000 }, 'Upload failed');
});
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:photo-a.jpg');

act(() => { fireEvent.click(screen.getByRole('button')); });
act(() => {
dropzoneCallbacks.onAddedFile({ name: 'photo-b.jpg', size: 20000, type: 'image/jpeg' });
dropzoneCallbacks.onFileCompleted({ name: 'photo-b.jpg', size: 20000 });
dropzoneCallbacks.onUploadComplete({ name: 'server_photo-b_xyz.jpg', size: 20000 }, 'test-upload', {});
});

rerender(<UploadInputV3 {...defaultProps} value={[{ filename: 'server_photo-b_xyz.jpg', size: 20000 }]} maxFiles={2} />);

const img = screen.getByRole('img', { name: 'server_photo-b_xyz.jpg' });
expect(img).toHaveAttribute('src', 'blob:photo-b.jpg');
expect(img).not.toHaveAttribute('src', 'blob:photo-a.jpg');
});
});

describe('Edge Cases', () => {
test('handles empty value array', () => {
const { container } = render(<UploadInputV3 {...defaultProps} value={[]} />);
Expand Down
109 changes: 64 additions & 45 deletions src/components/inputs/upload-input-v3/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* limitations under the License.
**/

import React, { useState, useRef, useMemo, useCallback, useEffect } from 'react';
import React, { useState, useRef, useMemo, useCallback, useLayoutEffect } from 'react';
import T from "i18n-react/dist/i18n-react";
import {
Box,
Expand All @@ -30,6 +30,19 @@ import ProgressiveImg from '../../progressive-img';
import file_icon from '../upload-input/file.png';
import './index.less';

const fileRowSx = {
display: 'flex',
alignItems: 'center',
py: 1.5,
mb: 1,
};

const formatFileSize = (bytes) => {
if (!bytes) return '0 KB';
if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MB`;
return `${Math.round(bytes / 1024)} KB`;
};

const UploadInputV3 = ({
value = [],
onRemove,
Expand All @@ -54,26 +67,17 @@ const UploadInputV3 = ({
const dropzoneInstanceRef = useRef(null);
const [uploadingFiles, setUploadingFiles] = useState([]);
const [errorFiles, setErrorFiles] = useState([]);
const [filePreviews, setFilePreviews] = useState({});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add lifecycle cleanup for cached blob URLs.

filePreviews entries are never revoked on component unmount, so object URLs can leak memory beyond the upload flow. Add a cleanup effect that revokes all remaining blob URLs.

Suggested patch
+  React.useEffect(() => {
+    return () => {
+      Object.values(filePreviews).forEach((url) => {
+        if (url) URL.revokeObjectURL(url);
+      });
+    };
+  }, [filePreviews]);

Also applies to: 169-172

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/inputs/upload-input-v3/index.js` at line 70, Add a useEffect
that revokes cached blob URLs on unmount and whenever filePreviews changes:
implement useEffect(() => { return () => {
Object.values(filePreviews).forEach(url => { if (url) URL.revokeObjectURL(url)
}) } }, [filePreviews]); this ensures any object URLs created earlier (look for
places that call URL.createObjectURL in the upload preview code around the
filePreviews setter) are revoked; also ensure any code path that replaces an
individual preview calls URL.revokeObjectURL on the old URL before overwriting
filePreviews via setFilePreviews.


const getDefaultAllowedExtensions = useCallback(() => {
return mediaType && mediaType.type
? mediaType?.type?.allowed_extensions.map((ext) => `.${ext.toLowerCase()}`).join(",")
: '';
}, [mediaType]);

const getDefaultMaxSize = useCallback(() => {
return mediaType ? mediaType?.max_size / (1024 * 1024) : 100;
}, [mediaType]);
const allowedExt = useMemo(() => {
if (getAllowedExtensions) return getAllowedExtensions();
return mediaType?.type?.allowed_extensions?.map(ext => `.${ext.toLowerCase()}`).join(',') ?? '';
}, [getAllowedExtensions, mediaType]);

const allowedExt = useMemo(() =>
getAllowedExtensions ? getAllowedExtensions() : getDefaultAllowedExtensions(),
[getAllowedExtensions, getDefaultAllowedExtensions]
);

const maxSize = useMemo(() =>
getMaxSize ? getMaxSize() : getDefaultMaxSize(),
[getMaxSize, getDefaultMaxSize]
);
const maxSize = useMemo(() => {
if (getMaxSize) return getMaxSize();
return mediaType ? mediaType.max_size / (1024 * 1024) : 100;
}, [getMaxSize, mediaType]);

const canUpload = useMemo(() =>
!maxFiles || value.length < maxFiles,
Expand Down Expand Up @@ -113,13 +117,7 @@ const UploadInputV3 = ({
media_upload: value,
}), [mediaType, value]);

const formatFileSize = useCallback((bytes) => {
if (!bytes) return '0 KB';
if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MB`;
return `${Math.round(bytes / 1024)} KB`;
}, []);

const formatExtensionsDisplay = useCallback(() => {
const extDisplay = useMemo(() => {
if (!allowedExt) return '';
const exts = allowedExt.split(',')
.map(e => e.trim().replace('.', '').toUpperCase())
Expand All @@ -131,15 +129,21 @@ const UploadInputV3 = ({

const handleRemove = useCallback((file) => (ev) => {
ev.preventDefault();
const blobUrl = filePreviews[file.filename];
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
setFilePreviews(prev => { const next = { ...prev }; delete next[file.filename]; return next; });
}
onRemove(file);
}, [onRemove]);
}, [onRemove, filePreviews]);

const handleDropzoneReady = useCallback((dz) => {
dropzoneInstanceRef.current = dz;
}, []);

const handleAddedFile = useCallback((file) => {
setUploadingFiles(prev => [...prev, { name: file.name, size: file.size, progress: 0, complete: false }]);
const previewUrl = file.type?.startsWith('image/') ? URL.createObjectURL(file) : null;
setUploadingFiles(prev => [...prev, { name: file.name, size: file.size, progress: 0, complete: false, previewUrl }]);
}, []);

const handleUploadProgress = useCallback((file, progress) => {
Expand All @@ -162,14 +166,17 @@ const UploadInputV3 = ({
));
}, []);

// Once the parent updates value, remove all completed files from uploadingFiles
useEffect(() => {
useLayoutEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see the use case for this change, can you explain ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useLayoutEffect runs synchronously before the browser paints, so when value updates, the completed uploading row is removed before the user sees anything. With useEffect it runs after paint, causing a one-frame flash where the file appears twice — once as "Complete" in the uploading section and once in the committed value section.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this just hides the symptom but does not address the re-render . which is caused by this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done the refactor you mention and prevValueRef, pendingPreviewsRef and the batched matching are all gone. But useLayoutEffect is still needed though: when value updates, both the completed uploading row and the new value row land in the same render, and without it the user sees them both for one frame before the cleanup runs. It's not hiding a symptom, it's preventing a real visual duplicate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm working on the last of your comments and will update this PR with the latest code, will tag you so you can review it.

if (uploadingFiles.length === 0 || value.length === 0) return;
setUploadingFiles(prev => prev.filter(f => !f.complete));
}, [value]);

const handleFileError = useCallback((file, message) => {
setUploadingFiles(prev => prev.filter(f => !(f.name === file.name && f.size === file.size)));
setUploadingFiles(prev => {
const entry = prev.find(f => f.name === file.name && f.size === file.size);
if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl);
return prev.filter(f => !(f.name === file.name && f.size === file.size));
});
setErrorFiles(prev => [...prev, { name: file.name, size: file.size, message }]);
}, []);

Expand All @@ -184,24 +191,32 @@ const UploadInputV3 = ({
}, []);

const handleDeleteUploading = useCallback((file) => {
setUploadingFiles(prev => {
const entry = prev.find(f => f.name === file.name && f.size === file.size);
if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl);
return prev.filter(f => !(f.name === file.name && f.size === file.size));
});
if (dropzoneInstanceRef.current) {
const dzFile = dropzoneInstanceRef.current.files?.find(
f => f.name === file.name && f.size === file.size
);
if (dzFile) dropzoneInstanceRef.current.removeFile(dzFile);
}
setUploadingFiles(prev => prev.filter(f => !(f.name === file.name && f.size === file.size)));
}, []);

const wrappedOnUploadComplete = useCallback((response, dzId, dzData) => {
// Mark fully-uploaded rows complete (covers HTTP 202 flow where handleFileCompleted was skipped).
// Guard against flipping rows whose bytes are still in flight when maxFiles > 1.
setUploadingFiles(prev => prev.map(f => (f.progress >= 100 ? { ...f, complete: true } : f)));
setUploadingFiles(prev => {
if (response?.name && response?.size) {
const entry = prev.find(f => f.size === response.size && f.previewUrl);
if (entry) setFilePreviews(p => ({ ...p, [response.name]: entry.previewUrl }));
}
return prev.map(f => (f.progress >= 100 ? { ...f, complete: true } : f));
});
if (onUploadComplete) onUploadComplete(response, dzId, dzData);
}, [onUploadComplete]);

const extDisplay = formatExtensionsDisplay();

const renderDropzone = () => {
if (!postUrl) {
return (
Expand Down Expand Up @@ -252,13 +267,6 @@ const UploadInputV3 = ({
);
};

const fileRowSx = {
display: 'flex',
alignItems: 'center',
py: 1.5,
mb: 1,
};

return (
<Box className="upload-input-v3">
{label && (
Expand Down Expand Up @@ -292,8 +300,18 @@ const UploadInputV3 = ({
key={`uploading-${index}`}
sx={fileRowSx}
>
<Box sx={{ color: 'primary.main', display: 'flex', alignItems: 'center', mr: 2, minWidth: 32 }}>
<UploadFileIcon fontSize="medium" />
<Box sx={{ display: 'flex', alignItems: 'center', mr: 2, width: 64, height: 64, flexShrink: 0 }}>
{file.previewUrl ? (
<ProgressiveImg
alt={file.name}
src={file.previewUrl}
placeholderSrc={file_icon}
/>
) : (
<Box sx={{ color: 'primary.main', display: 'flex', alignItems: 'center' }}>
<UploadFileIcon fontSize="medium" />
</Box>
)}
</Box>

<Box sx={{ flex: 1, minWidth: 0 }}>
Expand Down Expand Up @@ -377,7 +395,8 @@ const UploadInputV3 = ({
let src = file?.private_url || file?.public_url || file?.file_url;
if (src === '#') src = file?.public_url;
// custom replace for dropbox case ( download vs raw)
const previewSrc = src ? src.replace("?dl=0", "?raw=1") : filename;
const serverPreviewSrc = src ? src.replace("?dl=0", "?raw=1") : filename;
const previewSrc = filePreviews[filename] || serverPreviewSrc;

return (
<Box
Expand Down
Loading
Loading