Skip to content

feat: upload input v3 image preview improvement#263

Open
priscila-moneo wants to merge 2 commits into
mainfrom
feature/upload-input-v3-image-preview-improvement
Open

feat: upload input v3 image preview improvement#263
priscila-moneo wants to merge 2 commits into
mainfrom
feature/upload-input-v3-image-preview-improvement

Conversation

@priscila-moneo

@priscila-moneo priscila-moneo commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

ref: https://app.clickup.com/t/86ba8wmgh

Summary by CodeRabbit

  • New Features

    • Immediate image previews display when uploading files
  • Improvements

    • Enhanced loading performance for image previews
    • Improved preview handling during upload cancellations and errors
    • Better preview persistence across component updates
  • Tests

    • Comprehensive test coverage for image preview behavior and loading scenarios

@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR refactors file upload thumbnail previews by creating blob URLs directly in UploadInputV3 for newly added image files, optimizing ProgressiveImg to handle local URLs synchronously, and managing the preview lifecycle through proper revocation on cancel/error and transfer to state on completion.

Changes

Blob URL Preview Caching and Lifecycle

Layer / File(s) Summary
ProgressiveImg data URL and blob URL optimization
src/components/progressive-img/index.js, src/components/progressive-img/__tests__/progressive-img.test.js
ProgressiveImg now detects data: and blob: URLs and renders them synchronously without creating an Image object or waiting for async load handlers. Effect cancellation is refactored from useRef to a local cancelled boolean. Tests verify immediate rendering for local URLs and placeholder-to-real-image transitions for remote URLs.
UploadInputV3 preview state and helpers
src/components/inputs/upload-input-v3/index.js
Add filePreviews state to cache blob URL objects keyed by server filename, introduce formatFileSize helper and shared fileRowSx styling, and refactor extension/max-size computation to use prop-provided getters with mediaType fallbacks.
UploadInputV3 preview lifecycle management
src/components/inputs/upload-input-v3/index.js
Create blob URLs via URL.createObjectURL when image files are added to uploading rows. Revoke blob URLs on user cancel/remove or upload error. Transfer blob URLs into filePreviews state keyed by server response filename on successful completion. Switch parent-value synchronization to useLayoutEffect to filter out completed rows.
UploadInputV3 preview rendering
src/components/inputs/upload-input-v3/index.js
Render ProgressiveImg with blob URL in uploading-file rows when previewUrl exists; otherwise show upload icon. Prefer locally cached filePreviews[filename] over server-derived preview URL in completed-files list.
UploadInputV3 image preview test suite
src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js
Mock URL.createObjectURL/URL.revokeObjectURL and verify previews appear immediately for image files, persist across parent value updates, are revoked on cancel, map correctly in parallel uploads with out-of-order responses, and are revoked on error without carryover to subsequent uploads.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • OpenStackweb/openstack-uicore-foundation#257: Both PRs modify src/components/inputs/upload-input-v3/index.js to render image thumbnails via ProgressiveImg, with overlapping changes to how preview src values are computed/updated and corresponding upload-input-v3 tests around preview behavior.
  • OpenStackweb/openstack-uicore-foundation#226: Both PRs modify src/components/inputs/upload-input-v3/index.js in the upload lifecycle—changing completion/state reconciliation logic and how completed rows are moved/removed.
  • OpenStackweb/openstack-uicore-foundation#200: Both PRs modify the same UploadInputV3 implementation, so the main PR's preview/object-URL lifecycle changes are directly related to the v3 component introduced in the retrieved PR.

Suggested reviewers

  • smarcet
  • santipalenque

Poem

🐰 Blob URLs bloom bright, no more waiting for sight,
Local previews render quick, a thumbnail trick,
Cancel, revoke, complete—lifecycle complete,
Images cached in state, no server wait!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: improving image preview handling in UploadInputV3 component, which is the primary focus of the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/upload-input-v3-image-preview-improvement

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/components/inputs/upload-input-v3/index.js`:
- Around line 182-194: The current preview assignment uses FIFO shift on
pendingPreviewsRef.current and assumes server-returned value order matches
upload order, causing mis-matches; update the logic to match previews to files
by a stable identifier instead of insertion order: ensure preview entries in
pendingPreviewsRef include the original client filename (e.g.,
preview.originalName or preview.clientFilename) at enqueue time, build a map
from that original name to dataURL, then iterate newFiles (from value) and for
each file try to match by comparing f.filename or f.originalName to the preview
map key (and use a heuristic like contains/endsWith if the server adds
prefixes); assign matched previews into updates and only fall back to shift()
for any remaining unmatched files; update the code paths around
pendingPreviewsRef, newFiles, and setFilePreviews to use this name-based
matching.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7d9718e3-5485-4d80-a2ed-9b8ff5300c12

📥 Commits

Reviewing files that changed from the base of the PR and between 2539b02 and 4610604.

📒 Files selected for processing (3)
  • src/components/inputs/upload-input-v3/dropzone-v3.js
  • src/components/inputs/upload-input-v3/index.js
  • src/components/progressive-img/index.js

Comment thread src/components/inputs/upload-input-v3/index.js Outdated
Signed-off-by: Priscila Moneo <priscila_moneo@hotmail.com.ar>
@priscila-moneo priscila-moneo force-pushed the feature/upload-input-v3-image-preview-improvement branch from 4610604 to 32e967f Compare June 5, 2026 21:25
* @constructor
*/
const ProgressiveImg = ({ placeholderSrc, src, ...props }) => {
const isCancelled = useRef(false);

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.

are you sure to do this ? is not the same to use a Ref or a let

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.

Yes, I know. This change is intentional. The useRef is shared across all effect runs, so when src changes, the cleanup sets isCancelled.current = true on the shared ref — and the new effect's onload sees true and bails out immediately, leaving the image stuck in the blurred placeholder state forever. The let cancelled closure variable is scoped to each individual effect run, so each one starts fresh with false.

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

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.

what happens if changing for another file with same name and same size ?

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.

Fair point — while it's an edge case (same name + same size is essentially the same file), I've replaced dataURL from the thumbnail event with URL.createObjectURL(file) in addedfile. Each File object gets a unique blob URL by reference, so collisions are impossible regardless of name or size. As a bonus, the preview is now available immediately when the file is selected instead of waiting for Dropzone to generate the thumbnail. Blob URLs are revoked on cancel/error to avoid memory leaks.

const [errorFiles, setErrorFiles] = useState([]);
const [filePreviews, setFilePreviews] = useState({});
const prevValueRef = useRef(value);
const pendingPreviewsRef = useRef([]);

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.

seems like filePreviews already has this information, why you need this ref ?

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.

Simplification: collapse the preview state into a single store

The current design stores each dataURL in two places simultaneously — uploadingFiles[i].previewUrl (for the in-progress row) and pendingPreviewsRef (so useLayoutEffect can re-key it by
server filename later). This forces three separate cleanup sites that must all stay in sync: handleFileError, handleDeleteUploading, and useLayoutEffect.
All of this goes away if handleThumbnail stores directly into filePreviews keyed by the original filename:

const handleThumbnail = useCallback((file, dataURL) => {
setFilePreviews(prev => ({ ...prev, [file.name]: dataURL }));
}, []);

Then:

  • Uploading rows read filePreviews[file.name] at render time — previewUrl can be dropped from uploadingFiles and handleAddedFile no longer needs to set it.
  • Committed value rows already fall back to serverPreviewSrc, so the stem-match can be done inline at read time: filePreviews[filename] || filePreviews[Object.keys(filePreviews).find(k =>
    filename.includes(k.replace(/.[^.]+$/, '')))] || serverPreviewSrc.
  • pendingPreviewsRef, prevValueRef, and the entire batched matching algorithm in useLayoutEffect are removed.
  • Cleanup on cancel/error becomes a single setFilePreviews delete instead of a coordinated mutation across two collections.

// 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(() => {

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.

const entry =
avail(e => e.name === f.filename) ??
avail(e => { const s = e.name.replace(/\.[^.]+$/, ''); return s && f.filename.includes(s); }) ??
avail(() => true);

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.

on multiple uploads at same time, this can potentially match the wrong file. Worth a comment at least

Signed-off-by: Priscila Moneo <priscila_moneo@hotmail.com.ar>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/components/inputs/upload-input-v3/index.js (1)

210-214: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Preview mapping is still ambiguous for same-size concurrent uploads.

At Line 212, matching uses only response.size, so two different files with identical sizes can receive swapped previews. Please map with a stable per-file identifier (client upload id echoed by server, or an explicit correlation token) instead of size-only matching.

🤖 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` around lines 210 - 214, The
current preview-assignment logic in the setUploadingFiles callback uses
response.size to match upload entries (response.name/response.size and
entry.previewUrl), which causes wrong previews for concurrent same-size files;
change the protocol to use a stable per-file identifier (e.g. response.uploadId
or response.correlationId) returned by the server and store that id on the
client-side upload entries, then in setUploadingFiles find the entry by that
uploadId instead of size and call setFilePreviews with the stable key (e.g.
setFilePreviews(p => ({ ...p, [response.uploadId]: entry.previewUrl }))). Update
any code that creates upload entries to include the client upload id so matching
is deterministic.
🧹 Nitpick comments (2)
src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js (2)

350-358: ⚡ Quick win

Preserve and restore original URL methods instead of deleting globals.

At Line 356–Line 357, deleting global methods can pollute other tests. Store originals and restore them in afterEach for isolation.

🤖 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/__tests__/upload-input-v3.test.js`
around lines 350 - 358, The tests are deleting global URL methods which can leak
into other tests; modify the beforeEach/afterEach so beforeEach saves the
originals (e.g., const originalCreateObjectURL = URL.createObjectURL; const
originalRevokeObjectURL = URL.revokeObjectURL) then mocks with jest.fn in
beforeEach, and in afterEach restore them (URL.createObjectURL =
originalCreateObjectURL; URL.revokeObjectURL = originalRevokeObjectURL) instead
of using delete; update the beforeEach/afterEach in upload-input-v3.test.js
around the existing beforeEach/afterEach blocks referencing URL.createObjectURL
and URL.revokeObjectURL.

414-434: ⚡ Quick win

Add a collision test for parallel uploads with identical file sizes.

Current coverage validates out-of-order responses but only with unique sizes. Add a case where two image uploads have the same size to catch preview mis-assignment regressions.

🤖 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/__tests__/upload-input-v3.test.js`
around lines 414 - 434, Add a new unit test alongside the existing "correctly
maps previews for parallel uploads using response size" that reproduces a
collision where two uploads have identical size: use the same pattern of calling
dropzoneCallbacks.onAddedFile for two distinct filenames (e.g., 'sunset.jpg' and
'portrait.jpg') with identical size values, call
dropzoneCallbacks.onFileCompleted for both, then simulate server responses in
reverse order via dropzoneCallbacks.onUploadComplete with server filenames
(e.g., '246_portrait_abc123.jpg' and '246_sunset_def456.jpg'), rerender
UploadInputV3 with value containing those server filenames and sizes, and assert
that screen.getByRole('img', { name: ... }) returns the expected blob src
mapping to the original preview names (e.g., 'blob:portrait.jpg' and
'blob:sunset.jpg') to catch preview mis-assignment when sizes collide.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/components/inputs/upload-input-v3/index.js`:
- 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.

---

Duplicate comments:
In `@src/components/inputs/upload-input-v3/index.js`:
- Around line 210-214: The current preview-assignment logic in the
setUploadingFiles callback uses response.size to match upload entries
(response.name/response.size and entry.previewUrl), which causes wrong previews
for concurrent same-size files; change the protocol to use a stable per-file
identifier (e.g. response.uploadId or response.correlationId) returned by the
server and store that id on the client-side upload entries, then in
setUploadingFiles find the entry by that uploadId instead of size and call
setFilePreviews with the stable key (e.g. setFilePreviews(p => ({ ...p,
[response.uploadId]: entry.previewUrl }))). Update any code that creates upload
entries to include the client upload id so matching is deterministic.

---

Nitpick comments:
In `@src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js`:
- Around line 350-358: The tests are deleting global URL methods which can leak
into other tests; modify the beforeEach/afterEach so beforeEach saves the
originals (e.g., const originalCreateObjectURL = URL.createObjectURL; const
originalRevokeObjectURL = URL.revokeObjectURL) then mocks with jest.fn in
beforeEach, and in afterEach restore them (URL.createObjectURL =
originalCreateObjectURL; URL.revokeObjectURL = originalRevokeObjectURL) instead
of using delete; update the beforeEach/afterEach in upload-input-v3.test.js
around the existing beforeEach/afterEach blocks referencing URL.createObjectURL
and URL.revokeObjectURL.
- Around line 414-434: Add a new unit test alongside the existing "correctly
maps previews for parallel uploads using response size" that reproduces a
collision where two uploads have identical size: use the same pattern of calling
dropzoneCallbacks.onAddedFile for two distinct filenames (e.g., 'sunset.jpg' and
'portrait.jpg') with identical size values, call
dropzoneCallbacks.onFileCompleted for both, then simulate server responses in
reverse order via dropzoneCallbacks.onUploadComplete with server filenames
(e.g., '246_portrait_abc123.jpg' and '246_sunset_def456.jpg'), rerender
UploadInputV3 with value containing those server filenames and sizes, and assert
that screen.getByRole('img', { name: ... }) returns the expected blob src
mapping to the original preview names (e.g., 'blob:portrait.jpg' and
'blob:sunset.jpg') to catch preview mis-assignment when sizes collide.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 060f9ed7-3bec-49bb-a60b-9791fd9adcfc

📥 Commits

Reviewing files that changed from the base of the PR and between 4610604 and ca65214.

📒 Files selected for processing (4)
  • src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js
  • src/components/inputs/upload-input-v3/index.js
  • src/components/progressive-img/__tests__/progressive-img.test.js
  • src/components/progressive-img/index.js
✅ Files skipped from review due to trivial changes (1)
  • src/components/progressive-img/tests/progressive-img.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/components/progressive-img/index.js

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.

@santipalenque santipalenque left a comment

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.

LGTM, thank you @priscila-moneo

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants