Skip to content
Merged
12 changes: 12 additions & 0 deletions dev/upload.html
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,17 @@
<vaadin-radio-button value="rejected" label="Rejected"></vaadin-radio-button>
<vaadin-radio-button value="error" label="Server error"></vaadin-radio-button>
</vaadin-radio-group>
<hr style="margin-block: 24px" />
<h3>no-auto + max-concurrent-uploads=1</h3>
<p>Test: Add multiple files, click "Start" on the first file, then try clicking "Start" on another file. The start button should hide and show 0% progress while queued.</p>
<vaadin-upload id="no-auto-upload" target="/api/fileupload" no-auto max-concurrent-uploads="1"></vaadin-upload>
<script type="module">
import { xhrCreator } from '@vaadin/upload/test/helpers.js';

const noAutoUpload = document.querySelector('#no-auto-upload');
noAutoUpload._createXhr = () => {
return xhrCreator({ size: 512, uploadTime: 5000, stepTime: 1000 })();
};
</script>
</body>
</html>
23 changes: 23 additions & 0 deletions packages/upload/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,29 @@ Once installed, import the component in your application:
import '@vaadin/upload';
```

## Performance Considerations

When uploading large numbers of files, the component automatically throttles concurrent uploads to prevent browser performance degradation. By default, a maximum of 3 files are uploaded simultaneously, with additional files queued automatically.

You can customize this limit using the `max-concurrent-uploads` attribute:

```html
<!-- Limit to 5 concurrent uploads -->
<vaadin-upload max-concurrent-uploads="5"></vaadin-upload>
```

```js
// Or set it programmatically
upload.maxConcurrentUploads = 5;
```

This helps prevent:
- Browser XHR limitations (failures when uploading 2000+ files simultaneously)
- Performance degradation with hundreds of concurrent uploads
- Network congestion on slower connections

The default value of 3 balances upload performance with network resource conservation.

## Contributing

Read the [contributing guide](https://vaadin.com/docs/latest/contributing) to learn about our development process, how to propose bugfixes and improvements, and how to test your changes to Vaadin components.
Expand Down
7 changes: 5 additions & 2 deletions packages/upload/src/vaadin-upload-file.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ class UploadFile extends UploadFileMixin(ThemableMixin(PolylitMixin(LumoInjectio

/** @protected */
render() {
const isFileStartVisible = this.held && !this.uploading && !this.complete;
const isFileRetryVisible = this.errorMessage;

return html`
<div part="done-icon" ?hidden="${!this.complete}" aria-hidden="true"></div>
<div part="warning-icon" ?hidden="${!this.errorMessage}" aria-hidden="true"></div>
Expand All @@ -83,7 +86,7 @@ class UploadFile extends UploadFileMixin(ThemableMixin(PolylitMixin(LumoInjectio
part="start-button"
file-event="file-start"
@click="${this._fireFileEvent}"
?hidden="${!this.held}"
?hidden="${!isFileStartVisible}"
?disabled="${this.disabled}"
aria-label="${this.i18n ? this.i18n.file.start : nothing}"
aria-describedby="name"
Expand All @@ -93,7 +96,7 @@ class UploadFile extends UploadFileMixin(ThemableMixin(PolylitMixin(LumoInjectio
part="retry-button"
file-event="file-retry"
@click="${this._fireFileEvent}"
?hidden="${!this.errorMessage}"
?hidden="${!isFileRetryVisible}"
?disabled="${this.disabled}"
aria-label="${this.i18n ? this.i18n.file.retry : nothing}"
aria-describedby="name"
Expand Down
10 changes: 10 additions & 0 deletions packages/upload/src/vaadin-upload-mixin.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,16 @@ export declare class UploadMixinClass {
*/
uploadFormat: UploadFormat;

/**
* Specifies the maximum number of files that can be uploaded simultaneously.
* This helps prevent browser performance degradation and XHR limitations when
* uploading large numbers of files. Files exceeding this limit will be queued
* and uploaded as active uploads complete.
* @attr {number} max-concurrent-uploads
* @default 3
*/
maxConcurrentUploads: number;

/**
* The object used to localize this component. To change the default
* localization, replace this with an object that provides all properties, or
Expand Down
85 changes: 77 additions & 8 deletions packages/upload/src/vaadin-upload-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,20 @@ export const UploadMixin = (superClass) =>
value: 'raw',
},

/**
* Specifies the maximum number of files that can be uploaded simultaneously.
* This helps prevent browser performance degradation and XHR limitations when
* uploading large numbers of files. Files exceeding this limit will be queued
* and uploaded as active uploads complete.
* @attr {number} max-concurrent-uploads
* @type {number}
*/
maxConcurrentUploads: {
type: Number,
value: 3,
sync: true,
},

/**
* Pass-through to input's capture attribute. Allows user to trigger device inputs
* such as camera or microphone immediately.
Expand All @@ -347,6 +361,18 @@ export const UploadMixin = (superClass) =>
_files: {
type: Array,
},

/** @private */
_uploadQueue: {
type: Array,
value: () => [],
},

/** @private */
_activeUploads: {
type: Number,
value: 0,
},
};
}

Expand Down Expand Up @@ -694,16 +720,46 @@ export const UploadMixin = (superClass) =>
if (files && !Array.isArray(files)) {
files = [files];
}
files = files.filter((file) => !file.complete);
Array.prototype.forEach.call(files, this._uploadFile.bind(this));
files.filter((file) => !file.complete).forEach((file) => this._queueFileUpload(file));
}

/** @private */
_uploadFile(file) {
_queueFileUpload(file) {
if (file.uploading) {
return;
}

file.held = true;
file.uploading = file.indeterminate = true;
file.complete = file.abort = file.error = false;
file.status = this.__effectiveI18n.uploading.status.held;
this._renderFileList();

this._uploadQueue.push(file);
this._processUploadQueue();
}

/**
* Process the upload queue by starting uploads for queued files
* if there is available capacity.
*
* @private
*/
_processUploadQueue() {
// Process as many queued files as we have capacity for
while (this._uploadQueue.length > 0 && this._activeUploads < this.maxConcurrentUploads) {
const nextFile = this._uploadQueue.shift();
if (nextFile) {
this._uploadFile(nextFile);
}
}
}

/** @private */
_uploadFile(file) {
// Increment active uploads counter
this._activeUploads += 1;

const ini = Date.now();
const xhr = (file.xhr = this._createXhr());

Expand Down Expand Up @@ -740,11 +796,22 @@ export const UploadMixin = (superClass) =>
this.dispatchEvent(new CustomEvent('upload-progress', { detail: { file, xhr } }));
};

xhr.onabort = () => {
// Decrement active uploads counter
this._activeUploads -= 1;
this._processUploadQueue();
};

// More reliable than xhr.onload
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
clearTimeout(stalledId);
file.indeterminate = file.uploading = false;

// Decrement active uploads counter
this._activeUploads -= 1;
this._processUploadQueue();

if (file.abort) {
return;
}
Expand Down Expand Up @@ -815,9 +882,8 @@ export const UploadMixin = (superClass) =>
xhr.open(this.method, file.uploadTarget, true);
this._configureXhr(xhr, file, isRawUpload);

file.held = false;
file.status = this.__effectiveI18n.uploading.status.connecting;
file.uploading = file.indeterminate = true;
file.complete = file.abort = file.error = file.held = false;

xhr.upload.onloadstart = () => {
this.dispatchEvent(
Expand Down Expand Up @@ -862,7 +928,7 @@ export const UploadMixin = (superClass) =>
}),
);
if (evt) {
this._uploadFile(file);
this._queueFileUpload(file);
this._updateFocus(this.files.indexOf(file));
}
}
Expand Down Expand Up @@ -934,7 +1000,7 @@ export const UploadMixin = (superClass) =>
this.files = [file, ...this.files];

if (!this.noAuto) {
this._uploadFile(file);
this._queueFileUpload(file);
}
}

Expand All @@ -957,6 +1023,9 @@ export const UploadMixin = (superClass) =>
* @protected
*/
_removeFile(file) {
this._uploadQueue = this._uploadQueue.filter((f) => f !== file);
this._processUploadQueue();

const fileIndex = this.files.indexOf(file);
if (fileIndex >= 0) {
this.files = this.files.filter((i) => i !== file);
Expand Down Expand Up @@ -998,7 +1067,7 @@ export const UploadMixin = (superClass) =>

/** @private */
_onFileStart(event) {
this._uploadFile(event.detail.file);
this._queueFileUpload(event.detail.file);
}

/** @private */
Expand Down
16 changes: 13 additions & 3 deletions packages/upload/test/adding-files.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe('adding files', () => {

beforeEach(async () => {
upload = fixtureSync(`<vaadin-upload></vaadin-upload>`);
upload.target = 'http://foo.com/bar';
upload.target = 'https://foo.com/bar';
upload._createXhr = xhrCreator({ size: testFileSize, uploadTime: 200, stepTime: 50 });
await nextRender();
files = createFiles(2, testFileSize, 'application/x-octet-stream');
Expand Down Expand Up @@ -332,12 +332,21 @@ describe('adding files', () => {

describe('start upload', () => {
it('should automatically start upload', () => {
upload.maxConcurrentUploads = 1;
const uploadStartSpy = sinon.spy();
upload.addEventListener('upload-start', uploadStartSpy);

files.forEach(upload._addFile.bind(upload));
expect(uploadStartSpy.calledTwice).to.be.true;
expect(upload.files[0].held).to.be.false;
// With queue behavior, only the first file starts uploading immediately
expect(uploadStartSpy.calledOnce).to.be.true;

// Files are prepended, so the first file added is at index 1
expect(upload.files[1].held).to.be.false;
expect(upload.files[1].uploading).to.be.true;

// Second file (at index 0) should be queued
expect(upload.files[0].held).to.be.true;
expect(upload.files[0].uploading).to.be.true;
});

it('should not automatically start upload when noAuto flag is set', () => {
Expand All @@ -348,6 +357,7 @@ describe('adding files', () => {
files.forEach(upload._addFile.bind(upload));
expect(uploadStartSpy.called).to.be.false;
expect(upload.files[0].held).to.be.true;
expect(upload.files[0].uploading).to.not.be.true;
});
});

Expand Down
Loading