payloadcms / payloadcms/payload
Bulk upload: an interrupted upload is dropped from the retry queue and counted as a success
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 44.8k
- Forks
- 4.2k
- Avg merge
- 2d 21h
- Merged PRs (30d)
- 53
Description
Describe the Bug
In the bulk upload drawer, if the POST for one file rejects — a dropped connection, an aborted request, a DNS blip mid-upload — that file is silently dropped from the retry queue and counted as a success. The user sees "Successfully saved N files" and the file is gone, with no indication anything failed.
In packages/ui/src/elements/BulkUpload/FormsManager/index.tsx, saveAllDocs wraps each upload in a try/catch that swallows the rejection (v3.89.0, lines 520-522):
} catch (_) {
// swallow
}
Every path inside the try assigns currentForms[i].errorCount before moving on. The catch does not, so a rejected fetch leaves the form's errorCount at the 0 it was initialised with in the reducer's ADD_FORMS.
The retry queue is then rebuilt from exactly that field (lines 529-538):
const remainingForms = []
currentForms.forEach(({ errorCount }, i) => {
if (errorCount) {
remainingForms.push(currentForms[i])
}
})
const successCount = Math.max(0, currentForms.length - remainingForms.length)
const errorCount = currentForms.length - successCount
So the interrupted form is not pushed into remainingForms, and because successCount is derived by subtraction rather than from the documents actually created, it is counted as saved. toast.success('Successfully saved N files') fires, setSuccessfullyUploaded(true) runs, and if it was the only failure errorCount is 0 and the drawer closes. newDocs — the array actually appended to on a 201 — correctly does not contain it, which is what makes the mismatch visible.
Link to the code that reproduces this issue
Reproduction Steps
- Open any upload-enabled collection and start a bulk upload with two or more files.
- Cause the upload
POSTfor one of them to reject rather than return an error status. Offline-mode mid-save works; so does stubbing it in the console before clicking save:
const realFetch = window.fetch
let dropped = false
window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input.url
if (!dropped && init?.method === 'POST' && url.includes('/api/media')) {
dropped = true
return Promise.reject(new TypeError('Failed to fetch'))
}
return realFetch(input, init)
}
- Click save.
Expected: the interrupted file stays in the drawer as a failed form so it can be retried, and is not counted in the success total.
Actual: it disappears from the drawer, Successfully saved N files counts it, and no error is shown for it.
Which area(s) are affected? (Select all that apply)
area: ui
Environment Info
Payload: 3.88.0 (verified unchanged in 3.89.0)
Node.js: 22
Next.js: 15.4.11
Suggested fix
Give the catch the same responsibility every other branch has — mark the form failed so it survives into remainingForms:
} catch (_) {
currentForms[i].errorCount = currentForms[i].errorCount || 1
}
Deriving successCount from newDocs.length instead of by subtraction would also make the count robust to any future branch that forgets to set errorCount.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/ui/src/elements/BulkUpload/FormsManager/index.tsx at saveAllDocs and inspect the catch and remainingForms logic around lines 520-538. Reproduce the rejected POST with the described offline-mode or fetch stub, then verify the interrupted file remains available for retry and is excluded from the success count.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 85/100