From 8c59ac014497f5fca6808b7c89d031d8e61efadb Mon Sep 17 00:00:00 2001 From: Priscila Moneo Date: Fri, 5 Jun 2026 17:58:08 -0300 Subject: [PATCH 1/8] feat: upload input v3 image preview improvement Signed-off-by: Priscila Moneo --- .../__tests__/upload-input-v3.test.js | 111 ++++++++++++++++++ .../inputs/upload-input-v3/index.js | 109 ++++++++++------- .../__tests__/progressive-img.test.js | 106 +++++++++++++++++ src/components/progressive-img/index.js | 30 +++-- 4 files changed, 300 insertions(+), 56 deletions(-) create mode 100644 src/components/progressive-img/__tests__/progressive-img.test.js diff --git a/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js b/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js index af4e87aa..92ca491c 100644 --- a/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js +++ b/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js @@ -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(); + 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(); + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); diff --git a/src/components/inputs/upload-input-v3/index.js b/src/components/inputs/upload-input-v3/index.js index 202134b3..fc35f34e 100644 --- a/src/components/inputs/upload-input-v3/index.js +++ b/src/components/inputs/upload-input-v3/index.js @@ -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, @@ -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, @@ -55,26 +68,17 @@ const UploadInputV3 = ({ const dropzoneInstanceRef = useRef(null); const [uploadingFiles, setUploadingFiles] = useState([]); const [errorFiles, setErrorFiles] = useState([]); + const [filePreviews, setFilePreviews] = useState({}); - 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, @@ -114,13 +118,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()) @@ -132,15 +130,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 }]); if (onUploadStart) onUploadStart(file); }, [onUploadStart]); @@ -164,14 +168,17 @@ const UploadInputV3 = ({ )); }, []); - // Once the parent updates value, remove all completed files from uploadingFiles - useEffect(() => { + useLayoutEffect(() => { 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 }]); }, []); @@ -186,24 +193,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 ( @@ -254,13 +269,6 @@ const UploadInputV3 = ({ ); }; - const fileRowSx = { - display: 'flex', - alignItems: 'center', - py: 1.5, - mb: 1, - }; - return ( {label && ( @@ -294,8 +302,18 @@ const UploadInputV3 = ({ key={`uploading-${index}`} sx={fileRowSx} > - - + + {file.previewUrl ? ( + + ) : ( + + + + )} @@ -379,7 +397,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 ( { + describe('local src (dataURL / blob URL)', () => { + test('renders dataURL immediately without placeholder or blur', () => { + const dataURL = 'data:image/jpeg;base64,abc123'; + render(); + const img = screen.getByRole('img', { name: 'test image' }); + expect(img).toHaveAttribute('src', dataURL); + expect(img.className).toContain('loaded'); + expect(img.className).not.toContain('loading'); + }); + + test('renders blob URL immediately without placeholder or blur', () => { + const blobURL = 'blob:photo.jpg'; + render(); + const img = screen.getByRole('img', { name: 'test image' }); + expect(img).toHaveAttribute('src', blobURL); + expect(img.className).toContain('loaded'); + expect(img.className).not.toContain('loading'); + }); + + test('updates immediately when src changes to a local URL', () => { + const serverURL = 'https://cdn.example.com/photo.jpg'; + const blobURL = 'blob:photo.jpg'; + const { rerender } = render(); + + rerender(); + + const img = screen.getByRole('img', { name: 'test image' }); + expect(img).toHaveAttribute('src', blobURL); + expect(img.className).toContain('loaded'); + }); + }); + + describe('URL src', () => { + let mockImageInstances; + + beforeEach(() => { + mockImageInstances = []; + global.Image = jest.fn().mockImplementation(() => { + const instance = { src: '', onload: null, onerror: null }; + mockImageInstances.push(instance); + return instance; + }); + }); + + afterEach(() => { + delete global.Image; + }); + + test('renders placeholder initially while loading a URL', () => { + render(); + const img = screen.getByRole('img', { name: 'test' }); + expect(img).toHaveAttribute('src', 'placeholder.png'); + expect(img.className).toContain('loading'); + }); + + test('switches to actual src on successful load', () => { + const src = 'https://cdn.example.com/photo.jpg'; + render(); + act(() => { mockImageInstances[0].onload(); }); + const img = screen.getByRole('img', { name: 'test' }); + expect(img).toHaveAttribute('src', src); + expect(img.className).toContain('loaded'); + }); + + test('falls back to file_icon on error for unknown extension', () => { + render(); + act(() => { mockImageInstances[0].onerror(); }); + const img = screen.getByRole('img', { name: 'test' }); + expect(img.className).toContain('loaded'); + expect(img).not.toHaveAttribute('src', 'https://cdn.example.com/photo.jpg'); + }); + + test('does not apply stale src when src changes before the first load completes', () => { + const firstURL = 'https://cdn.example.com/slow.jpg'; + const secondURL = 'https://cdn.example.com/fast.jpg'; + const { rerender } = render(); + + // Change src before firstURL loads — simulates the serverURL → dataURL swap in upload flow + rerender(); + + // Trigger the first (now-cancelled) effect's onload + act(() => { mockImageInstances[0].onload(); }); + + const img = screen.getByRole('img', { name: 'test' }); + expect(img).not.toHaveAttribute('src', firstURL); + }); + }); +}); diff --git a/src/components/progressive-img/index.js b/src/components/progressive-img/index.js index 8c9ba7ec..f48c171b 100644 --- a/src/components/progressive-img/index.js +++ b/src/components/progressive-img/index.js @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ -import React,{ useState, useEffect, useRef } from "react"; +import React,{ useState, useEffect } from "react"; import styles from './index.module.scss'; import pdf_icon from "../inputs/upload-input/pdf.png"; import mov_icon from "../inputs/upload-input/mov.png"; @@ -26,23 +26,31 @@ import file_icon from "../inputs/upload-input/file.png"; * @constructor */ const ProgressiveImg = ({ placeholderSrc, src, ...props }) => { - const isCancelled = useRef(false); - const [imgSrc, setImgSrc] = useState(placeholderSrc || src); - const [customClass, setCustomClass] = useState(styles.loading); + const isLocal = src?.startsWith('data:') || src?.startsWith('blob:'); + const [imgSrc, setImgSrc] = useState(isLocal ? src : (placeholderSrc || src)); + const [customClass, setCustomClass] = useState(isLocal ? styles.loaded : styles.loading); useEffect(() => { + // dataURLs and blob URLs are already in memory — no async loading needed + if (src?.startsWith('data:') || src?.startsWith('blob:')) { + setImgSrc(src); + setCustomClass(styles.loaded); + return; + } + + let cancelled = false; const img = new Image(); - const ext = src ? src.split('.').pop() : null; + const ext = src ? src.split('.').pop() : null; img.src = src; img.onload = () => { - if (isCancelled.current) return - setImgSrc(src) - setCustomClass(styles.loaded) + if (cancelled) return; + setImgSrc(src); + setCustomClass(styles.loaded); }; img.onerror = () => { - if (isCancelled.current) return + if (cancelled) return; img.onerror = null; if(ext && ext.toString().toLowerCase().includes('pdf')) setImgSrc(pdf_icon) @@ -54,11 +62,11 @@ const ProgressiveImg = ({ placeholderSrc, src, ...props }) => { setImgSrc(csv_icon); else setImgSrc(file_icon); - setCustomClass(styles.loaded) + setCustomClass(styles.loaded); }; return () => { - isCancelled.current = true; + cancelled = true; }; }, [src]); From 0061872bad8ed0eb3b25b1f0d19444f288052ade Mon Sep 17 00:00:00 2001 From: Priscila Moneo Date: Wed, 24 Jun 2026 19:27:27 -0300 Subject: [PATCH 2/8] fix: feedback from PR Signed-off-by: Priscila Moneo --- .../inputs/upload-input-v3/index.js | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/components/inputs/upload-input-v3/index.js b/src/components/inputs/upload-input-v3/index.js index fc35f34e..a4c57032 100644 --- a/src/components/inputs/upload-input-v3/index.js +++ b/src/components/inputs/upload-input-v3/index.js @@ -11,7 +11,7 @@ * limitations under the License. **/ -import React, { useState, useRef, useMemo, useCallback, useLayoutEffect } from 'react'; +import React, { useState, useRef, useMemo, useCallback, useLayoutEffect, useEffect } from 'react'; import T from "i18n-react/dist/i18n-react"; import { Box, @@ -69,6 +69,17 @@ const UploadInputV3 = ({ const [uploadingFiles, setUploadingFiles] = useState([]); const [errorFiles, setErrorFiles] = useState([]); const [filePreviews, setFilePreviews] = useState({}); + const filePreviewsRef = useRef({}); + filePreviewsRef.current = filePreviews; + const uploadingFilesRef = useRef([]); + uploadingFilesRef.current = uploadingFiles; + + useEffect(() => { + return () => { + Object.values(filePreviewsRef.current).forEach(url => { if (url) URL.revokeObjectURL(url); }); + uploadingFilesRef.current.forEach(f => { if (f.previewUrl) URL.revokeObjectURL(f.previewUrl); }); + }; + }, []); const allowedExt = useMemo(() => { if (getAllowedExtensions) return getAllowedExtensions(); @@ -170,7 +181,12 @@ const UploadInputV3 = ({ useLayoutEffect(() => { if (uploadingFiles.length === 0 || value.length === 0) return; - setUploadingFiles(prev => prev.filter(f => !f.complete)); + const valueFilenames = new Set(value.map(f => f.filename)); + setUploadingFiles(prev => prev.filter(f => { + if (!f.complete) return true; + // Only remove once the parent confirms receipt via value; untracked rows drop immediately. + return f.serverFilename ? !valueFilenames.has(f.serverFilename) : false; + })); }, [value]); const handleFileError = useCallback((file, message) => { @@ -208,13 +224,20 @@ const UploadInputV3 = ({ 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. + // Tag the specific matched row with serverFilename so the layout effect can remove it only + // once the parent confirms receipt via value — avoids pruning sibling rows in parallel uploads. 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 })); + const serverFilename = response?.name; + const matchedEntry = response?.size + ? prev.find(f => f.size === response.size && f.previewUrl) + : null; + if (matchedEntry && serverFilename) { + setFilePreviews(p => ({ ...p, [serverFilename]: matchedEntry.previewUrl })); } - return prev.map(f => (f.progress >= 100 ? { ...f, complete: true } : f)); + return prev.map(f => { + if (f.progress < 100) return f; + return { ...f, complete: true, ...(f === matchedEntry && serverFilename ? { serverFilename } : {}) }; + }); }); if (onUploadComplete) onUploadComplete(response, dzId, dzData); }, [onUploadComplete]); From ca165f558fd4a7672e1f8caf46351e9492d9d182 Mon Sep 17 00:00:00 2001 From: smarcet Date: Thu, 25 Jun 2026 17:36:56 -0300 Subject: [PATCH 3/8] fix(upload-input-v3): remove setState side effect from wrappedOnUploadComplete updater MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move setFilePreviews call out of the setUploadingFiles updater function into useLayoutEffect, where it runs as a top-level side effect after the parent confirms the upload via the value prop. The updater is now pure: it computes matchedEntry from prev and tags the matched entry with serverFilename. useLayoutEffect then reads the current uploading entries via uploadingFilesRef, transfers any confirmed blob previews to filePreviews, and removes the completed rows — all batched into one render. This avoids a React contract violation (setState inside an updater is a side effect) and makes the code safe for React 18 Strict Mode, which double-invokes updater functions in development. --- .../inputs/upload-input-v3/index.js | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/components/inputs/upload-input-v3/index.js b/src/components/inputs/upload-input-v3/index.js index a4c57032..ddb3d571 100644 --- a/src/components/inputs/upload-input-v3/index.js +++ b/src/components/inputs/upload-input-v3/index.js @@ -180,8 +180,22 @@ const UploadInputV3 = ({ }, []); useLayoutEffect(() => { - if (uploadingFiles.length === 0 || value.length === 0) return; + const currentUploading = uploadingFilesRef.current; + if (currentUploading.length === 0 || value.length === 0) return; const valueFilenames = new Set(value.map(f => f.filename)); + + // Transfer blob previews to filePreviews before removing confirmed entries, + // so the committed value row can display the preview immediately. + const newPreviews = {}; + currentUploading.forEach(f => { + if (f.complete && f.serverFilename && f.previewUrl && valueFilenames.has(f.serverFilename)) { + newPreviews[f.serverFilename] = f.previewUrl; + } + }); + if (Object.keys(newPreviews).length > 0) { + setFilePreviews(p => ({ ...p, ...newPreviews })); + } + setUploadingFiles(prev => prev.filter(f => { if (!f.complete) return true; // Only remove once the parent confirms receipt via value; untracked rows drop immediately. @@ -223,17 +237,13 @@ const UploadInputV3 = ({ }, []); const wrappedOnUploadComplete = useCallback((response, dzId, dzData) => { - // Mark fully-uploaded rows complete (covers HTTP 202 flow where handleFileCompleted was skipped). - // Tag the specific matched row with serverFilename so the layout effect can remove it only - // once the parent confirms receipt via value — avoids pruning sibling rows in parallel uploads. + // Pure updater: tag the matched entry with serverFilename so useLayoutEffect + // can transfer its previewUrl to filePreviews once the parent confirms via value. setUploadingFiles(prev => { const serverFilename = response?.name; const matchedEntry = response?.size ? prev.find(f => f.size === response.size && f.previewUrl) : null; - if (matchedEntry && serverFilename) { - setFilePreviews(p => ({ ...p, [serverFilename]: matchedEntry.previewUrl })); - } return prev.map(f => { if (f.progress < 100) return f; return { ...f, complete: true, ...(f === matchedEntry && serverFilename ? { serverFilename } : {}) }; From c91931eabe616dde67ade2eb2799b44fbde15f57 Mon Sep 17 00:00:00 2001 From: smarcet Date: Thu, 25 Jun 2026 17:45:19 -0300 Subject: [PATCH 4/8] fix(upload-input-v3): revoke blob URL in handleFileRemoved and handleDeleteUploading handleFileRemoved never called URL.revokeObjectURL, leaving blob URLs allocated when Dropzone's removedfile event fires through paths other than the custom delete button (e.g. programmatic removeFile calls, built-in Dropzone remove link). handleDeleteUploading had revokeObjectURL inside a setState updater (side effect in a pure function). Since delete always follows a committed render, the ref holds current state and the revocation can safely move outside the updater. handleFileError is intentionally left reading from prev inside the updater: Dropzone can fire error in the same synchronous tick as addedfile, before React commits the pending state update, so uploadingFilesRef is stale at that point. revokeObjectURL is idempotent so the side effect is safe there. --- src/components/inputs/upload-input-v3/index.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/inputs/upload-input-v3/index.js b/src/components/inputs/upload-input-v3/index.js index ddb3d571..54d04af0 100644 --- a/src/components/inputs/upload-input-v3/index.js +++ b/src/components/inputs/upload-input-v3/index.js @@ -166,6 +166,8 @@ const UploadInputV3 = ({ }, []); const handleFileRemoved = useCallback((file) => { + const entry = uploadingFilesRef.current.find(f => f.name === file.name && f.size === file.size); + if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl); setUploadingFiles(prev => prev.filter(f => !(f.name === file.name && f.size === file.size))); }, []); @@ -223,11 +225,9 @@ 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)); - }); + const entry = uploadingFilesRef.current.find(f => f.name === file.name && f.size === file.size); + if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl); + setUploadingFiles(prev => 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 From fff25f58ec93d725bec1204fb6fcd828bea57c06 Mon Sep 17 00:00:00 2001 From: smarcet Date: Thu, 25 Jun 2026 17:50:30 -0300 Subject: [PATCH 5/8] fix(upload-input-v3): prevent preview collision for parallel uploads of same-size images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When two images with identical file sizes were uploaded in parallel, both onUploadComplete callbacks matched the same first uploadingFiles entry via prev.find(f => f.size === response.size && f.previewUrl), causing both server-renamed files to display the first image's blob preview. Fix: add !f.serverFilename to the find condition. Once an entry is matched and tagged with serverFilename, it is excluded from subsequent matches. This works because functional updaters always receive the accumulated queue state, so the second updater sees the first entry already claimed. Regression test: parallel upload of two images with identical size (10000 bytes), server responds in order — each server file must map to its own blob preview. --- .../__tests__/upload-input-v3.test.js | 21 +++++++++++++++++++ .../inputs/upload-input-v3/index.js | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js b/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js index 92ca491c..79a1a3d8 100644 --- a/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js +++ b/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js @@ -433,6 +433,27 @@ describe('UploadInputV3', () => { expect(screen.getByRole('img', { name: '246_sunset_def456.jpg' })).toHaveAttribute('src', 'blob:sunset.jpg'); }); + test('correctly maps previews for parallel uploads of images with identical file sizes', () => { + const { rerender } = render(); + + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'alpha.jpg', size: 10000, type: 'image/jpeg' }); + dropzoneCallbacks.onAddedFile({ name: 'beta.jpg', size: 10000, type: 'image/jpeg' }); + dropzoneCallbacks.onFileCompleted({ name: 'alpha.jpg', size: 10000 }); + dropzoneCallbacks.onFileCompleted({ name: 'beta.jpg', size: 10000 }); + dropzoneCallbacks.onUploadComplete({ name: 'server_alpha_111.jpg', size: 10000 }, 'test-upload', {}); + dropzoneCallbacks.onUploadComplete({ name: 'server_beta_222.jpg', size: 10000 }, 'test-upload', {}); + }); + + rerender(); + + expect(screen.getByRole('img', { name: 'server_alpha_111.jpg' })).toHaveAttribute('src', 'blob:alpha.jpg'); + expect(screen.getByRole('img', { name: 'server_beta_222.jpg' })).toHaveAttribute('src', 'blob:beta.jpg'); + }); + test('revokes blob URL on error and does not assign it to the next upload', () => { const { rerender } = render(); diff --git a/src/components/inputs/upload-input-v3/index.js b/src/components/inputs/upload-input-v3/index.js index 54d04af0..2e6ac74f 100644 --- a/src/components/inputs/upload-input-v3/index.js +++ b/src/components/inputs/upload-input-v3/index.js @@ -242,7 +242,7 @@ const UploadInputV3 = ({ setUploadingFiles(prev => { const serverFilename = response?.name; const matchedEntry = response?.size - ? prev.find(f => f.size === response.size && f.previewUrl) + ? prev.find(f => f.size === response.size && f.previewUrl && !f.serverFilename) : null; return prev.map(f => { if (f.progress < 100) return f; From 54d8e0f44b744df6cdbc2d737dbd8c1a23461600 Mon Sep 17 00:00:00 2001 From: smarcet Date: Thu, 25 Jun 2026 17:54:30 -0300 Subject: [PATCH 6/8] test(upload-input-v3): add coverage for handleFileRemoved blob URL revocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the path where Dropzone fires removedfile directly (e.g. built-in remove link, programmatic removeFile) without going through the custom delete button. Previously untested — handleDeleteUploading was covered but handleFileRemoved was not. --- .../__tests__/upload-input-v3.test.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js b/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js index 79a1a3d8..ef89afdb 100644 --- a/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js +++ b/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js @@ -454,6 +454,19 @@ describe('UploadInputV3', () => { expect(screen.getByRole('img', { name: 'server_beta_222.jpg' })).toHaveAttribute('src', 'blob:beta.jpg'); }); + test('revokes blob URL when Dropzone fires removedfile directly without the delete button', () => { + render(); + + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'photo.jpg', size: 50000, type: 'image/jpeg' }); + }); + act(() => { + dropzoneCallbacks.onFileRemoved({ name: 'photo.jpg', size: 50000 }); + }); + + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:photo.jpg'); + }); + test('revokes blob URL on error and does not assign it to the next upload', () => { const { rerender } = render(); From b4e8f03bbbadc55defb784e3c852e12f262692e5 Mon Sep 17 00:00:00 2001 From: smarcet Date: Thu, 25 Jun 2026 18:16:24 -0300 Subject: [PATCH 7/8] fix(upload-input-v3): prevent premature completion of sibling files in parallel 202 uploads wrappedOnUploadComplete now uses dual size-based matching with a !serverFilename guard to tag only the specific entry that matches the server response, not all entries at the same size. This prevents a parallel HTTP-202 upload from prematurely marking a still-polling sibling file as complete. Adds regression test: two same-size files through 202 async path - only the file whose polling finishes gets marked complete. --- .../__tests__/upload-input-v3.test.js | 46 ++++++++++++++++--- .../inputs/upload-input-v3/index.js | 13 ++++-- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js b/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js index ef89afdb..4458005d 100644 --- a/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js +++ b/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js @@ -560,12 +560,12 @@ describe('UploadInputV3', () => { dropzoneCallbacks.onAddedFile({ name: 'video.mp4', size: 5000000 }); }); - // All chunks finish uploading — progress reaches 100 before async polling starts + // All chunks finish uploading - progress reaches 100 before async polling starts act(() => { dropzoneCallbacks.onUploadProgress({ name: 'video.mp4', size: 5000000 }, 100); }); - // handleFileCompleted fires with _asyncProcessing flag (HTTP 202 case) — file stays Loading + // handleFileCompleted fires with _asyncProcessing flag (HTTP 202 case) - file stays Loading act(() => { dropzoneCallbacks.onFileCompleted({ name: 'video.mp4', size: 5000000, _asyncProcessing: true }); }); @@ -574,9 +574,9 @@ describe('UploadInputV3', () => { expect(screen.getByText(/Loading/)).toBeInTheDocument(); expect(screen.queryByText(/Complete/)).not.toBeInTheDocument(); - // Polling completes — onUploadComplete fires after async processing finishes + // Polling completes - onUploadComplete fires after async processing finishes act(() => { - dropzoneCallbacks.onUploadComplete({ name: 'video_final.mp4' }, 'test-upload', {}); + dropzoneCallbacks.onUploadComplete({ name: 'video_final.mp4', size: 5000000 }, 'test-upload', {}); }); // Assert: file should now show "Complete" @@ -601,12 +601,12 @@ describe('UploadInputV3', () => { expect(screen.getByText('file-b.png')).toBeInTheDocument(); expect(screen.getAllByText(/Loading/)).toHaveLength(2); - // File A finishes uploading all chunks — progress reaches 100 + // File A finishes uploading all chunks - progress reaches 100 act(() => { dropzoneCallbacks.onUploadProgress({ name: 'file-a.png', size: 10000 }, 100); }); - // File B is still mid-upload — progress at 40 + // File B is still mid-upload - progress at 40 act(() => { dropzoneCallbacks.onUploadProgress({ name: 'file-b.png', size: 20000 }, 40); }); @@ -621,9 +621,41 @@ describe('UploadInputV3', () => { dropzoneCallbacks.onUploadComplete({ name: 'file-a.png' }, 'test-upload', {}); }); - // Assert: file B should still show "Loading" — it has not finished uploading + // Assert: file B should still show "Loading" - it has not finished uploading expect(screen.getByText('file-b.png')).toBeInTheDocument(); expect(screen.getByText(/Loading/)).toBeInTheDocument(); }); + + test('HTTP 202 parallel: onUploadComplete for one file does not prematurely mark sibling file as complete', () => { + // Two same-size non-image files both go through async HTTP 202 path. + // When polling completes for file A only, file B must stay Loading. + render(); + + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'clip-a.mp4', size: 8000000 }); + dropzoneCallbacks.onAddedFile({ name: 'clip-b.mp4', size: 8000000 }); + }); + + // Both hit 100% progress and get HTTP 202 - neither is complete yet + act(() => { + dropzoneCallbacks.onUploadProgress({ name: 'clip-a.mp4', size: 8000000 }, 100); + dropzoneCallbacks.onUploadProgress({ name: 'clip-b.mp4', size: 8000000 }, 100); + dropzoneCallbacks.onFileCompleted({ name: 'clip-a.mp4', size: 8000000, _asyncProcessing: true }); + dropzoneCallbacks.onFileCompleted({ name: 'clip-b.mp4', size: 8000000, _asyncProcessing: true }); + }); + + expect(screen.getAllByText(/Loading/)).toHaveLength(2); + + // Only clip-a.mp4 polling finishes + act(() => { + dropzoneCallbacks.onUploadComplete({ name: 'clip-a_server.mp4', size: 8000000 }, 'test-upload', {}); + }); + + // clip-b must still be Loading - it has not finished async processing + expect(screen.getByText('clip-b.mp4')).toBeInTheDocument(); + expect(screen.getByText(/Loading/)).toBeInTheDocument(); + // Only one Loading entry should remain (clip-b) + expect(screen.getAllByText(/Loading/)).toHaveLength(1); + }); }); }); diff --git a/src/components/inputs/upload-input-v3/index.js b/src/components/inputs/upload-input-v3/index.js index 2e6ac74f..ade31cac 100644 --- a/src/components/inputs/upload-input-v3/index.js +++ b/src/components/inputs/upload-input-v3/index.js @@ -241,12 +241,19 @@ const UploadInputV3 = ({ // can transfer its previewUrl to filePreviews once the parent confirms via value. setUploadingFiles(prev => { const serverFilename = response?.name; - const matchedEntry = response?.size + // Image files: match by size + unclaimed previewUrl. + // Non-image files: match by size among unclaimed entries (no previewUrl). + // Either way, only THIS file is marked complete - prevents sibling 202 files + // at 100% progress from being prematurely completed before their polling finishes. + const matchedByPreview = response?.size ? prev.find(f => f.size === response.size && f.previewUrl && !f.serverFilename) : null; + const match = matchedByPreview ?? (response?.size + ? prev.find(f => f.size === response.size && !f.previewUrl && !f.serverFilename) + : null); return prev.map(f => { - if (f.progress < 100) return f; - return { ...f, complete: true, ...(f === matchedEntry && serverFilename ? { serverFilename } : {}) }; + if (f !== match) return f; + return { ...f, complete: true, ...(serverFilename ? { serverFilename } : {}) }; }); }); if (onUploadComplete) onUploadComplete(response, dzId, dzData); From 4474a8c937e369e3a2cd5c48c7c4cf80184983b3 Mon Sep 17 00:00:00 2001 From: smarcet Date: Thu, 25 Jun 2026 18:27:10 -0300 Subject: [PATCH 8/8] feat(upload-input-v3): use original_name from API response for precise file matching When the server returns original_name in the upload response, match the uploading entry by client filename instead of file size. This resolves the same-size parallel upload ambiguity definitively: two images of identical byte size now get correct previews even when server responses arrive out of order. The size-based dual matching is kept as a fallback for any consumer that has not yet deployed the updated file-upload-api. --- .../__tests__/upload-input-v3.test.js | 25 +++++++++++++++++++ .../inputs/upload-input-v3/index.js | 25 +++++++++++-------- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js b/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js index 4458005d..53ac5474 100644 --- a/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js +++ b/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js @@ -454,6 +454,31 @@ describe('UploadInputV3', () => { expect(screen.getByRole('img', { name: 'server_beta_222.jpg' })).toHaveAttribute('src', 'blob:beta.jpg'); }); + test('original_name in response resolves preview assignment for same-size parallel images', () => { + // When the API returns original_name, same-size files are matched by name not size. + // alpha.jpg and beta.jpg are the same size - without original_name the previews + // could be swapped; with it they must be correct regardless of response order. + const { rerender } = render(); + + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'alpha.jpg', size: 10000, type: 'image/jpeg' }); + dropzoneCallbacks.onAddedFile({ name: 'beta.jpg', size: 10000, type: 'image/jpeg' }); + dropzoneCallbacks.onFileCompleted({ name: 'alpha.jpg', size: 10000 }); + dropzoneCallbacks.onFileCompleted({ name: 'beta.jpg', size: 10000 }); + // Server returns beta first (out-of-order), both with original_name + dropzoneCallbacks.onUploadComplete({ name: 'server_beta_222.jpg', size: 10000, original_name: 'beta.jpg' }, 'test-upload', {}); + dropzoneCallbacks.onUploadComplete({ name: 'server_alpha_111.jpg', size: 10000, original_name: 'alpha.jpg' }, 'test-upload', {}); + }); + + rerender(); + + expect(screen.getByRole('img', { name: 'server_alpha_111.jpg' })).toHaveAttribute('src', 'blob:alpha.jpg'); + expect(screen.getByRole('img', { name: 'server_beta_222.jpg' })).toHaveAttribute('src', 'blob:beta.jpg'); + }); + test('revokes blob URL when Dropzone fires removedfile directly without the delete button', () => { render(); diff --git a/src/components/inputs/upload-input-v3/index.js b/src/components/inputs/upload-input-v3/index.js index ade31cac..5adcb9f4 100644 --- a/src/components/inputs/upload-input-v3/index.js +++ b/src/components/inputs/upload-input-v3/index.js @@ -241,16 +241,21 @@ const UploadInputV3 = ({ // can transfer its previewUrl to filePreviews once the parent confirms via value. setUploadingFiles(prev => { const serverFilename = response?.name; - // Image files: match by size + unclaimed previewUrl. - // Non-image files: match by size among unclaimed entries (no previewUrl). - // Either way, only THIS file is marked complete - prevents sibling 202 files - // at 100% progress from being prematurely completed before their polling finishes. - const matchedByPreview = response?.size - ? prev.find(f => f.size === response.size && f.previewUrl && !f.serverFilename) - : null; - const match = matchedByPreview ?? (response?.size - ? prev.find(f => f.size === response.size && !f.previewUrl && !f.serverFilename) - : null); + let match; + if (response?.original_name) { + // Exact match by original client filename - resolves same-size parallel ambiguity. + match = prev.find(f => f.name === response.original_name && !f.serverFilename); + } else { + // Fallback for APIs that do not return original_name: match by size. + // Image files prefer entries with a previewUrl so same-size non-images + // are not accidentally claimed first. + const matchedByPreview = response?.size + ? prev.find(f => f.size === response.size && f.previewUrl && !f.serverFilename) + : null; + match = matchedByPreview ?? (response?.size + ? prev.find(f => f.size === response.size && !f.previewUrl && !f.serverFilename) + : null); + } return prev.map(f => { if (f !== match) return f; return { ...f, complete: true, ...(serverFilename ? { serverFilename } : {}) };