From 32e967f9134e9b6cad9af1ddeb6331198fe994cb Mon Sep 17 00:00:00 2001 From: Priscila Moneo Date: Fri, 5 Jun 2026 17:58:08 -0300 Subject: [PATCH 1/2] feat: upload input v3 image preview improvement Signed-off-by: Priscila Moneo --- .../__tests__/upload-input-v3.test.js | 137 ++++++++++++++++++ .../inputs/upload-input-v3/dropzone-v3.js | 5 + .../inputs/upload-input-v3/index.js | 66 ++++++++- .../__tests__/progressive-img.test.js | 98 +++++++++++++ src/components/progressive-img/index.js | 30 ++-- 5 files changed, 318 insertions(+), 18 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..ee86e21a 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,143 @@ describe('UploadInputV3', () => { }); }); + describe('Image Preview', () => { + test('shows thumbnail preview image during upload when onThumbnail fires', () => { + render(); + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'photo.jpg', size: 136000 }); + }); + act(() => { + dropzoneCallbacks.onThumbnail({ name: 'photo.jpg', size: 136000 }, 'data:image/jpeg;base64,abc123'); + }); + const img = screen.getByRole('img', { name: 'photo.jpg' }); + expect(img).toHaveAttribute('src', 'data:image/jpeg;base64,abc123'); + }); + + test('shows no preview image before onThumbnail fires (non-image or slow thumbnail)', () => { + render(); + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'document.pdf', size: 50000 }); + }); + expect(screen.queryByRole('img', { name: 'document.pdf' })).not.toBeInTheDocument(); + }); + + test('preserves dataURL preview after value updates with server-renamed filename', () => { + const dataURL = 'data:image/jpeg;base64,abc123'; + const { rerender } = render(); + + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'photo.jpg', size: 136000 }); + dropzoneCallbacks.onThumbnail({ name: 'photo.jpg', size: 136000 }, dataURL); + }); + act(() => { + dropzoneCallbacks.onFileCompleted({ name: 'photo.jpg', size: 136000 }); + }); + + rerender(); + + const img = screen.getByRole('img', { name: 'server_246_abc123.jpg' }); + expect(img).toHaveAttribute('src', dataURL); + }); + + test('does not assign cancelled file preview to the next upload', () => { + const dataURL_A = 'data:image/jpeg;base64,aaaa'; + const dataURL_B = 'data:image/jpeg;base64,bbbb'; + const { rerender } = render(); + + // Upload file A and generate its thumbnail + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'photo-a.jpg', size: 10000 }); + }); + act(() => { + dropzoneCallbacks.onThumbnail({ name: 'photo-a.jpg', size: 10000 }, dataURL_A); + }); + + // Cancel file A via the delete button + act(() => { + fireEvent.click(screen.getByRole('button')); + }); + + // Upload file B and generate its thumbnail + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'photo-b.jpg', size: 20000 }); + }); + act(() => { + dropzoneCallbacks.onThumbnail({ name: 'photo-b.jpg', size: 20000 }, dataURL_B); + }); + act(() => { + dropzoneCallbacks.onFileCompleted({ name: 'photo-b.jpg', size: 20000 }); + }); + + rerender(); + + const img = screen.getByRole('img', { name: 'server_photo_b.jpg' }); + expect(img).toHaveAttribute('src', dataURL_B); + expect(img).not.toHaveAttribute('src', dataURL_A); + }); + + test('matches preview by filename stem when server reorders parallel uploads', () => { + const dataURL_A = 'data:image/jpeg;base64,aaaa'; + const dataURL_B = 'data:image/jpeg;base64,bbbb'; + const { rerender } = render(); + + // Both files added and thumbnails generated + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'sunset.jpg', size: 10000 }); + dropzoneCallbacks.onThumbnail({ name: 'sunset.jpg', size: 10000 }, dataURL_A); + dropzoneCallbacks.onAddedFile({ name: 'portrait.jpg', size: 20000 }); + dropzoneCallbacks.onThumbnail({ name: 'portrait.jpg', size: 20000 }, dataURL_B); + }); + act(() => { + dropzoneCallbacks.onFileCompleted({ name: 'sunset.jpg', size: 10000 }); + dropzoneCallbacks.onFileCompleted({ name: 'portrait.jpg', size: 20000 }); + }); + + // Server returns them in REVERSE order — portrait before sunset + // Server filenames contain the original stem (common pattern: prefix_stem_hash.ext) + rerender(); + + expect(screen.getByRole('img', { name: '246_portrait_abc123.jpg' })).toHaveAttribute('src', dataURL_B); + expect(screen.getByRole('img', { name: '246_sunset_def456.jpg' })).toHaveAttribute('src', dataURL_A); + }); + + test('does not assign errored file preview to the next upload', () => { + const dataURL_A = 'data:image/jpeg;base64,aaaa'; + const dataURL_B = 'data:image/jpeg;base64,bbbb'; + const { rerender } = render(); + + // Upload file A, thumbnail fires, then it errors + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'photo-a.jpg', size: 10000 }); + dropzoneCallbacks.onThumbnail({ name: 'photo-a.jpg', size: 10000 }, dataURL_A); + }); + act(() => { + dropzoneCallbacks.onFileError({ name: 'photo-a.jpg', size: 10000 }, 'Upload failed'); + }); + + // Dismiss the error, then upload file B + act(() => { + fireEvent.click(screen.getByRole('button')); + }); + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'photo-b.jpg', size: 20000 }); + dropzoneCallbacks.onThumbnail({ name: 'photo-b.jpg', size: 20000 }, dataURL_B); + }); + act(() => { + dropzoneCallbacks.onFileCompleted({ name: 'photo-b.jpg', size: 20000 }); + }); + + rerender(); + + const img = screen.getByRole('img', { name: 'server_photo_b.jpg' }); + expect(img).toHaveAttribute('src', dataURL_B); + expect(img).not.toHaveAttribute('src', dataURL_A); + }); + }); + describe('Edge Cases', () => { test('handles empty value array', () => { const { container } = render(); diff --git a/src/components/inputs/upload-input-v3/dropzone-v3.js b/src/components/inputs/upload-input-v3/dropzone-v3.js index e9e99aeb..b2bfb1fe 100644 --- a/src/components/inputs/upload-input-v3/dropzone-v3.js +++ b/src/components/inputs/upload-input-v3/dropzone-v3.js @@ -24,6 +24,7 @@ export const DropzoneV3 = ({ onFileRemoved, onFileCompleted, onFileError, + onThumbnail, onDropzoneReady, eventHandlers = {}, children, @@ -39,6 +40,10 @@ export const DropzoneV3 = ({ if (onAddedFile) onAddedFile(file); if (eventHandlers.addedfile) eventHandlers.addedfile(file); }, + thumbnail: (file, dataURL) => { + if (onThumbnail) onThumbnail(file, dataURL); + if (eventHandlers.thumbnail) eventHandlers.thumbnail(file, dataURL); + }, removedfile: (file) => { if (onFileRemoved) onFileRemoved(file); if (eventHandlers.removedfile) eventHandlers.removedfile(file); diff --git a/src/components/inputs/upload-input-v3/index.js b/src/components/inputs/upload-input-v3/index.js index 134c8855..d98e790c 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, @@ -54,6 +54,9 @@ const UploadInputV3 = ({ const dropzoneInstanceRef = useRef(null); const [uploadingFiles, setUploadingFiles] = useState([]); const [errorFiles, setErrorFiles] = useState([]); + const [filePreviews, setFilePreviews] = useState({}); + const prevValueRef = useRef(value); + const pendingPreviewsRef = useRef([]); const getDefaultAllowedExtensions = useCallback(() => { return mediaType && mediaType.type @@ -139,7 +142,14 @@ const UploadInputV3 = ({ }, []); const handleAddedFile = useCallback((file) => { - setUploadingFiles(prev => [...prev, { name: file.name, size: file.size, progress: 0, complete: false }]); + setUploadingFiles(prev => [...prev, { name: file.name, size: file.size, progress: 0, complete: false, previewUrl: null }]); + }, []); + + const handleThumbnail = useCallback((file, dataURL) => { + pendingPreviewsRef.current.push({ name: file.name, size: file.size, dataURL }); + setUploadingFiles(prev => prev.map(f => + f.name === file.name && f.size === file.size ? { ...f, previewUrl: dataURL } : f + )); }, []); const handleUploadProgress = useCallback((file, progress) => { @@ -162,13 +172,39 @@ const UploadInputV3 = ({ )); }, []); - // Once the parent updates value, remove all completed files from uploadingFiles - useEffect(() => { + // Once the parent updates value, remove completed uploading files and assign local previews to new files + useLayoutEffect(() => { + const prevValue = prevValueRef.current; + prevValueRef.current = value; + if (uploadingFiles.length === 0 || value.length === 0) return; + + // Detect files newly added to value and assign queued dataURL previews (keyed by server filename) + const prevFilenames = new Set(prevValue.map(f => f.filename)); + const newFiles = value.filter(f => !prevFilenames.has(f.filename)); + if (newFiles.length > 0 && pendingPreviewsRef.current.length > 0) { + const matchedNames = new Set(); + const avail = (pred) => pendingPreviewsRef.current.find(e => !matchedNames.has(e.name) && pred(e)); + const updates = Object.fromEntries(newFiles.flatMap(f => { + const entry = + avail(e => e.name === f.filename) ?? + avail(e => { const s = e.name.replace(/\.[^.]+$/, ''); return s && f.filename.includes(s); }) ?? + avail(() => true); + if (!entry?.dataURL) return []; + matchedNames.add(entry.name); + return [[f.filename, entry.dataURL]]; + })); + pendingPreviewsRef.current = pendingPreviewsRef.current.filter(e => !matchedNames.has(e.name)); + if (Object.keys(updates).length > 0) setFilePreviews(prev => ({ ...prev, ...updates })); + } + setUploadingFiles(prev => prev.filter(f => !f.complete)); }, [value]); const handleFileError = useCallback((file, message) => { + pendingPreviewsRef.current = pendingPreviewsRef.current.filter( + p => !(p.name === file.name && p.size === file.size) + ); setUploadingFiles(prev => prev.filter(f => !(f.name === file.name && f.size === file.size))); setErrorFiles(prev => [...prev, { name: file.name, size: file.size, message }]); }, []); @@ -184,6 +220,9 @@ const UploadInputV3 = ({ }, []); const handleDeleteUploading = useCallback((file) => { + pendingPreviewsRef.current = pendingPreviewsRef.current.filter( + p => !(p.name === file.name && p.size === file.size) + ); if (dropzoneInstanceRef.current) { const dzFile = dropzoneInstanceRef.current.files?.find( f => f.name === file.name && f.size === file.size @@ -230,6 +269,7 @@ const UploadInputV3 = ({ onError={onError} onDropzoneReady={handleDropzoneReady} onAddedFile={handleAddedFile} + onThumbnail={handleThumbnail} onUploadProgress={handleUploadProgress} onFileRemoved={handleFileRemoved} onFileCompleted={handleFileCompleted} @@ -292,8 +332,18 @@ const UploadInputV3 = ({ key={`uploading-${index}`} sx={fileRowSx} > - - + + {file.previewUrl ? ( + + ) : ( + + + + )} @@ -377,7 +427,9 @@ 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; + // use the local dataURL preview from this session's upload if available + const previewSrc = filePreviews[filename] || serverPreviewSrc; return ( { + describe('dataURL src', () => { + 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('updates immediately when src changes to a dataURL', () => { + const serverURL = 'https://cdn.example.com/photo.jpg'; + const dataURL = 'data:image/jpeg;base64,abc123'; + const { rerender } = render(); + + rerender(); + + const img = screen.getByRole('img', { name: 'test image' }); + expect(img).toHaveAttribute('src', dataURL); + 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..e065db65 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 isDataURL = src?.startsWith('data:'); + const [imgSrc, setImgSrc] = useState(isDataURL ? src : (placeholderSrc || src)); + const [customClass, setCustomClass] = useState(isDataURL ? styles.loaded : styles.loading); useEffect(() => { + // dataURLs are already in memory — no async loading needed + if (src?.startsWith('data:')) { + 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 ca652146f9d6cec226c9c910b37855d3cc102b45 Mon Sep 17 00:00:00 2001 From: Priscila Moneo Date: Mon, 8 Jun 2026 17:23:49 -0300 Subject: [PATCH 2/2] fix: feedback from PR Signed-off-by: Priscila Moneo --- .../__tests__/upload-input-v3.test.js | 124 +++++++---------- .../inputs/upload-input-v3/dropzone-v3.js | 5 - .../inputs/upload-input-v3/index.js | 129 +++++++----------- .../__tests__/progressive-img.test.js | 22 ++- src/components/progressive-img/index.js | 10 +- 5 files changed, 117 insertions(+), 173 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 ee86e21a..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 @@ -347,139 +347,113 @@ describe('UploadInputV3', () => { }); describe('Image Preview', () => { - test('shows thumbnail preview image during upload when onThumbnail fires', () => { + 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 }); - }); - act(() => { - dropzoneCallbacks.onThumbnail({ name: 'photo.jpg', size: 136000 }, 'data:image/jpeg;base64,abc123'); + dropzoneCallbacks.onAddedFile({ name: 'photo.jpg', size: 136000, type: 'image/jpeg' }); }); const img = screen.getByRole('img', { name: 'photo.jpg' }); - expect(img).toHaveAttribute('src', 'data:image/jpeg;base64,abc123'); + expect(img).toHaveAttribute('src', 'blob:photo.jpg'); }); - test('shows no preview image before onThumbnail fires (non-image or slow thumbnail)', () => { + test('shows no preview for non-image files', () => { render(); act(() => { - dropzoneCallbacks.onAddedFile({ name: 'document.pdf', size: 50000 }); + dropzoneCallbacks.onAddedFile({ name: 'document.pdf', size: 50000, type: 'application/pdf' }); }); expect(screen.queryByRole('img', { name: 'document.pdf' })).not.toBeInTheDocument(); }); - test('preserves dataURL preview after value updates with server-renamed filename', () => { - const dataURL = 'data:image/jpeg;base64,abc123'; + test('preserves blob URL preview after value updates with server-renamed filename', () => { const { rerender } = render(); act(() => { - dropzoneCallbacks.onAddedFile({ name: 'photo.jpg', size: 136000 }); - dropzoneCallbacks.onThumbnail({ name: 'photo.jpg', size: 136000 }, dataURL); - }); - 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(); + rerender(); - const img = screen.getByRole('img', { name: 'server_246_abc123.jpg' }); - expect(img).toHaveAttribute('src', dataURL); + const img = screen.getByRole('img', { name: 'server_photo_abc123.jpg' }); + expect(img).toHaveAttribute('src', 'blob:photo.jpg'); }); - test('does not assign cancelled file preview to the next upload', () => { - const dataURL_A = 'data:image/jpeg;base64,aaaa'; - const dataURL_B = 'data:image/jpeg;base64,bbbb'; + test('revokes blob URL on cancel and does not assign it to the next upload', () => { const { rerender } = render(); - // Upload file A and generate its thumbnail - act(() => { - dropzoneCallbacks.onAddedFile({ name: 'photo-a.jpg', size: 10000 }); - }); act(() => { - dropzoneCallbacks.onThumbnail({ name: 'photo-a.jpg', size: 10000 }, dataURL_A); - }); - - // Cancel file A via the delete button - act(() => { - fireEvent.click(screen.getByRole('button')); + 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'); - // Upload file B and generate its thumbnail - act(() => { - dropzoneCallbacks.onAddedFile({ name: 'photo-b.jpg', size: 20000 }); - }); - act(() => { - dropzoneCallbacks.onThumbnail({ name: 'photo-b.jpg', size: 20000 }, dataURL_B); - }); 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(); + rerender(); - const img = screen.getByRole('img', { name: 'server_photo_b.jpg' }); - expect(img).toHaveAttribute('src', dataURL_B); - expect(img).not.toHaveAttribute('src', dataURL_A); + 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('matches preview by filename stem when server reorders parallel uploads', () => { - const dataURL_A = 'data:image/jpeg;base64,aaaa'; - const dataURL_B = 'data:image/jpeg;base64,bbbb'; + test('correctly maps previews for parallel uploads using response size', () => { const { rerender } = render(); - // Both files added and thumbnails generated - act(() => { - dropzoneCallbacks.onAddedFile({ name: 'sunset.jpg', size: 10000 }); - dropzoneCallbacks.onThumbnail({ name: 'sunset.jpg', size: 10000 }, dataURL_A); - dropzoneCallbacks.onAddedFile({ name: 'portrait.jpg', size: 20000 }); - dropzoneCallbacks.onThumbnail({ name: 'portrait.jpg', size: 20000 }, dataURL_B); - }); 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', {}); }); - // Server returns them in REVERSE order — portrait before sunset - // Server filenames contain the original stem (common pattern: prefix_stem_hash.ext) rerender(); - expect(screen.getByRole('img', { name: '246_portrait_abc123.jpg' })).toHaveAttribute('src', dataURL_B); - expect(screen.getByRole('img', { name: '246_sunset_def456.jpg' })).toHaveAttribute('src', dataURL_A); + 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('does not assign errored file preview to the next upload', () => { - const dataURL_A = 'data:image/jpeg;base64,aaaa'; - const dataURL_B = 'data:image/jpeg;base64,bbbb'; + test('revokes blob URL on error and does not assign it to the next upload', () => { const { rerender } = render(); - // Upload file A, thumbnail fires, then it errors - act(() => { - dropzoneCallbacks.onAddedFile({ name: 'photo-a.jpg', size: 10000 }); - dropzoneCallbacks.onThumbnail({ name: 'photo-a.jpg', size: 10000 }, dataURL_A); - }); 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'); - // Dismiss the error, then upload file B - act(() => { - fireEvent.click(screen.getByRole('button')); - }); - act(() => { - dropzoneCallbacks.onAddedFile({ name: 'photo-b.jpg', size: 20000 }); - dropzoneCallbacks.onThumbnail({ name: 'photo-b.jpg', size: 20000 }, dataURL_B); - }); + 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(); + rerender(); - const img = screen.getByRole('img', { name: 'server_photo_b.jpg' }); - expect(img).toHaveAttribute('src', dataURL_B); - expect(img).not.toHaveAttribute('src', dataURL_A); + 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'); }); }); diff --git a/src/components/inputs/upload-input-v3/dropzone-v3.js b/src/components/inputs/upload-input-v3/dropzone-v3.js index b2bfb1fe..e9e99aeb 100644 --- a/src/components/inputs/upload-input-v3/dropzone-v3.js +++ b/src/components/inputs/upload-input-v3/dropzone-v3.js @@ -24,7 +24,6 @@ export const DropzoneV3 = ({ onFileRemoved, onFileCompleted, onFileError, - onThumbnail, onDropzoneReady, eventHandlers = {}, children, @@ -40,10 +39,6 @@ export const DropzoneV3 = ({ if (onAddedFile) onAddedFile(file); if (eventHandlers.addedfile) eventHandlers.addedfile(file); }, - thumbnail: (file, dataURL) => { - if (onThumbnail) onThumbnail(file, dataURL); - if (eventHandlers.thumbnail) eventHandlers.thumbnail(file, dataURL); - }, removedfile: (file) => { if (onFileRemoved) onFileRemoved(file); if (eventHandlers.removedfile) eventHandlers.removedfile(file); diff --git a/src/components/inputs/upload-input-v3/index.js b/src/components/inputs/upload-input-v3/index.js index d98e790c..947e1898 100644 --- a/src/components/inputs/upload-input-v3/index.js +++ b/src/components/inputs/upload-input-v3/index.js @@ -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,28 +68,16 @@ const UploadInputV3 = ({ const [uploadingFiles, setUploadingFiles] = useState([]); const [errorFiles, setErrorFiles] = useState([]); const [filePreviews, setFilePreviews] = useState({}); - const prevValueRef = useRef(value); - const pendingPreviewsRef = useRef([]); - - 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(() => - getAllowedExtensions ? getAllowedExtensions() : getDefaultAllowedExtensions(), - [getAllowedExtensions, getDefaultAllowedExtensions] - ); - const maxSize = useMemo(() => - getMaxSize ? getMaxSize() : getDefaultMaxSize(), - [getMaxSize, getDefaultMaxSize] - ); + const allowedExt = useMemo(() => { + if (getAllowedExtensions) return getAllowedExtensions(); + return mediaType?.type?.allowed_extensions?.map(ext => `.${ext.toLowerCase()}`).join(',') ?? ''; + }, [getAllowedExtensions, mediaType]); + + const maxSize = useMemo(() => { + if (getMaxSize) return getMaxSize(); + return mediaType ? mediaType.max_size / (1024 * 1024) : 100; + }, [getMaxSize, mediaType]); const canUpload = useMemo(() => !maxFiles || value.length < maxFiles, @@ -116,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()) @@ -134,22 +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, previewUrl: null }]); - }, []); - - const handleThumbnail = useCallback((file, dataURL) => { - pendingPreviewsRef.current.push({ name: file.name, size: file.size, dataURL }); - setUploadingFiles(prev => prev.map(f => - f.name === file.name && f.size === file.size ? { ...f, previewUrl: dataURL } : f - )); + 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) => { @@ -172,40 +166,17 @@ const UploadInputV3 = ({ )); }, []); - // Once the parent updates value, remove completed uploading files and assign local previews to new files useLayoutEffect(() => { - const prevValue = prevValueRef.current; - prevValueRef.current = value; - if (uploadingFiles.length === 0 || value.length === 0) return; - - // Detect files newly added to value and assign queued dataURL previews (keyed by server filename) - const prevFilenames = new Set(prevValue.map(f => f.filename)); - const newFiles = value.filter(f => !prevFilenames.has(f.filename)); - if (newFiles.length > 0 && pendingPreviewsRef.current.length > 0) { - const matchedNames = new Set(); - const avail = (pred) => pendingPreviewsRef.current.find(e => !matchedNames.has(e.name) && pred(e)); - const updates = Object.fromEntries(newFiles.flatMap(f => { - const entry = - avail(e => e.name === f.filename) ?? - avail(e => { const s = e.name.replace(/\.[^.]+$/, ''); return s && f.filename.includes(s); }) ?? - avail(() => true); - if (!entry?.dataURL) return []; - matchedNames.add(entry.name); - return [[f.filename, entry.dataURL]]; - })); - pendingPreviewsRef.current = pendingPreviewsRef.current.filter(e => !matchedNames.has(e.name)); - if (Object.keys(updates).length > 0) setFilePreviews(prev => ({ ...prev, ...updates })); - } - setUploadingFiles(prev => prev.filter(f => !f.complete)); }, [value]); const handleFileError = useCallback((file, message) => { - pendingPreviewsRef.current = pendingPreviewsRef.current.filter( - p => !(p.name === file.name && p.size === file.size) - ); - 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 }]); }, []); @@ -220,27 +191,32 @@ const UploadInputV3 = ({ }, []); const handleDeleteUploading = useCallback((file) => { - pendingPreviewsRef.current = pendingPreviewsRef.current.filter( - p => !(p.name === file.name && p.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)); + }); 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 ( @@ -269,7 +245,6 @@ const UploadInputV3 = ({ onError={onError} onDropzoneReady={handleDropzoneReady} onAddedFile={handleAddedFile} - onThumbnail={handleThumbnail} onUploadProgress={handleUploadProgress} onFileRemoved={handleFileRemoved} onFileCompleted={handleFileCompleted} @@ -292,13 +267,6 @@ const UploadInputV3 = ({ ); }; - const fileRowSx = { - display: 'flex', - alignItems: 'center', - py: 1.5, - mb: 1, - }; - return ( {label && ( @@ -428,7 +396,6 @@ const UploadInputV3 = ({ if (src === '#') src = file?.public_url; // custom replace for dropbox case ( download vs raw) const serverPreviewSrc = src ? src.replace("?dl=0", "?raw=1") : filename; - // use the local dataURL preview from this session's upload if available const previewSrc = filePreviews[filename] || serverPreviewSrc; return ( diff --git a/src/components/progressive-img/__tests__/progressive-img.test.js b/src/components/progressive-img/__tests__/progressive-img.test.js index 9d2c1139..91522f4c 100644 --- a/src/components/progressive-img/__tests__/progressive-img.test.js +++ b/src/components/progressive-img/__tests__/progressive-img.test.js @@ -11,13 +11,12 @@ * limitations under the License. **/ -import React from 'react'; -import { render, screen, fireEvent, act } from '@testing-library/react'; +import { render, screen, act } from '@testing-library/react'; import '@testing-library/jest-dom'; import ProgressiveImg from '../index'; describe('ProgressiveImg', () => { - describe('dataURL src', () => { + describe('local src (dataURL / blob URL)', () => { test('renders dataURL immediately without placeholder or blur', () => { const dataURL = 'data:image/jpeg;base64,abc123'; render(); @@ -27,15 +26,24 @@ describe('ProgressiveImg', () => { expect(img.className).not.toContain('loading'); }); - test('updates immediately when src changes to a dataURL', () => { + 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 dataURL = 'data:image/jpeg;base64,abc123'; + const blobURL = 'blob:photo.jpg'; const { rerender } = render(); - rerender(); + rerender(); const img = screen.getByRole('img', { name: 'test image' }); - expect(img).toHaveAttribute('src', dataURL); + expect(img).toHaveAttribute('src', blobURL); expect(img.className).toContain('loaded'); }); }); diff --git a/src/components/progressive-img/index.js b/src/components/progressive-img/index.js index e065db65..f48c171b 100644 --- a/src/components/progressive-img/index.js +++ b/src/components/progressive-img/index.js @@ -26,13 +26,13 @@ import file_icon from "../inputs/upload-input/file.png"; * @constructor */ const ProgressiveImg = ({ placeholderSrc, src, ...props }) => { - const isDataURL = src?.startsWith('data:'); - const [imgSrc, setImgSrc] = useState(isDataURL ? src : (placeholderSrc || src)); - const [customClass, setCustomClass] = useState(isDataURL ? styles.loaded : 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 are already in memory — no async loading needed - if (src?.startsWith('data:')) { + // 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;