From 6cd2a754980551dec25dda4273b547eb020c9c2d Mon Sep 17 00:00:00 2001 From: isaacclad Date: Mon, 8 Jun 2026 13:36:27 +0000 Subject: [PATCH] Fix transfer create uploads getting stuck and failing on submit. All files now upload through chunked sessions only, the file picker no longer rewrites the queue on change, and create submits upload_ids instead of a broken multipart files field. Co-authored-by: Cursor --- resources/js/app.js | 110 ++++++++++-------- .../views/transfer/transfers/create.blade.php | 4 +- tests/Feature/TransferTest.php | 42 +++++++ 3 files changed, 103 insertions(+), 53 deletions(-) diff --git a/resources/js/app.js b/resources/js/app.js index 85c7ea5..b54b7f3 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,7 +1,7 @@ import './bootstrap'; import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals'; -import { shouldUseChunkedUpload, uploadFileChunked } from './chunked-upload'; +import { uploadFileChunked } from './chunked-upload'; registerLadillModalHelpers(); @@ -316,7 +316,10 @@ Alpine.data('transferCreateForm', (config = {}) => ({ fileItems: [], submitting: false, prepared: false, - _uploadQueue: Promise.resolve(), + _ignoreInputChange: false, + _uploadQueue: [], + _draining: false, + _drainPromise: null, get uploading() { return this.fileItems.some((item) => item.status === 'uploading' || item.status === 'pending'); @@ -348,17 +351,21 @@ Alpine.data('transferCreateForm', (config = {}) => ({ }, onFilesChange(event) { - const newFiles = Array.from(event.target.files || []); + if (this._ignoreInputChange) { + return; + } + + const picked = Array.from(event.target.files || []); + if (!picked.length) { + return; + } + this.prepared = false; + const existingKeys = new Set(this.fileItems.map((item) => item.key)); - const prevByKey = new Map(this.fileItems.map((item) => [item.key, item])); - this.fileItems = []; - - for (const file of newFiles) { + for (const file of picked) { const key = `${file.name}:${file.size}:${file.lastModified}`; - const prev = prevByKey.get(key); - if (prev && (prev.status === 'uploading' || prev.status === 'ready')) { - this.fileItems.push(prev); + if (existingKeys.has(key)) { continue; } @@ -368,25 +375,51 @@ Alpine.data('transferCreateForm', (config = {}) => ({ file, name: file.name, size: file.size, - status: shouldUseChunkedUpload(file.size) ? 'pending' : 'ready', + status: 'pending', progress: 0, - uploadId: prev?.uploadId || null, + uploadId: null, error: null, }; this.fileItems.push(item); - - if (item.status === 'pending') { - this.queueUpload(item); - } + this._uploadQueue.push(item); } - this.syncFileInput(event.target); + this.resetFileInput(event.target); + void this.drainUploadQueue(); }, - queueUpload(item) { - this._uploadQueue = this._uploadQueue - .then(() => this.uploadFileItem(item)) - .catch(() => {}); + resetFileInput(input) { + this._ignoreInputChange = true; + input.value = ''; + this._ignoreInputChange = false; + }, + + drainUploadQueue() { + if (this._drainPromise) { + return this._drainPromise; + } + + this._drainPromise = (async () => { + this._draining = true; + + while (this._uploadQueue.length) { + const item = this._uploadQueue.shift(); + if (!item || item.status !== 'pending') { + continue; + } + + await this.uploadFileItem(item); + } + + this._draining = false; + this._drainPromise = null; + + if (this._uploadQueue.length) { + void this.drainUploadQueue(); + } + })(); + + return this._drainPromise; }, async uploadFileItem(item) { @@ -424,27 +457,7 @@ Alpine.data('transferCreateForm', (config = {}) => ({ } this.fileItems = this.fileItems.filter((entry) => entry.id !== id); - this.syncFileInput(document.querySelector('#files')); - }, - - syncFileInput(input) { - if (!input) { - return; - } - - const smallFiles = this.fileItems - .filter((item) => item.status === 'ready' && item.file && !shouldUseChunkedUpload(item.size)) - .map((item) => item.file); - - if (smallFiles.length) { - const dataTransfer = new DataTransfer(); - smallFiles.forEach((file) => dataTransfer.items.add(file)); - input.files = dataTransfer.files; - input.required = true; - } else { - input.removeAttribute('required'); - input.value = ''; - } + this._uploadQueue = this._uploadQueue.filter((entry) => entry.id !== id); }, async submit(event) { @@ -454,7 +467,6 @@ Alpine.data('transferCreateForm', (config = {}) => ({ event.preventDefault(); const form = event.target; - const fileInput = form.querySelector('#files'); if (!this.fileItems.length) { window.alert('Add at least one file to share.'); @@ -467,7 +479,7 @@ Alpine.data('transferCreateForm', (config = {}) => ({ } if (this.uploading) { - await this._uploadQueue; + await this.drainUploadQueue(); } if (this.fileItems.some((item) => item.status === 'uploading' || item.status === 'pending')) { @@ -475,18 +487,15 @@ Alpine.data('transferCreateForm', (config = {}) => ({ return; } - if (!this.fileItems.some((item) => item.status === 'ready')) { + const readyItems = this.fileItems.filter((item) => item.status === 'ready' && item.uploadId); + if (!readyItems.length) { window.alert('Add at least one file to share.'); return; } form.querySelectorAll('input[name="upload_ids[]"]').forEach((node) => node.remove()); - for (const item of this.fileItems) { - if (!item.uploadId) { - continue; - } - + for (const item of readyItems) { const hidden = document.createElement('input'); hidden.type = 'hidden'; hidden.name = 'upload_ids[]'; @@ -494,7 +503,6 @@ Alpine.data('transferCreateForm', (config = {}) => ({ form.appendChild(hidden); } - this.syncFileInput(fileInput); this.submitting = true; this.prepared = true; form.submit(); diff --git a/resources/views/transfer/transfers/create.blade.php b/resources/views/transfer/transfers/create.blade.php index 32c70ee..3c2b89b 100644 --- a/resources/views/transfer/transfers/create.blade.php +++ b/resources/views/transfer/transfers/create.blade.php @@ -36,9 +36,9 @@
- -

Up to {{ $maxFiles }} files. Large files upload in the background with live progress.

+

Up to {{ $maxFiles }} files. Uploads start immediately — pick more files anytime before creating the transfer.