From d5818b1c58c43c37167754154019eda47906c8e8 Mon Sep 17 00:00:00 2001
From: Ryan Wang <thepoy@163.com>
Date: Sat, 13 Jan 2024 19:00:34 +0800
Subject: [PATCH 1/3] feat: support concurrency limit

When enabling multi-file upload, you can control the concurrency by providing the `concurrencyLimit` property.

The `concurrencyLimit` property creates a `ConcurrencyRequester` instance, storing all requests in the instance's queue. The concurrent upload task quantity is restricted by the `concurrencyLimit` when using the `send` method of the instance at the end of the `uploadFiles` process.
---
 README.md                 |  72 +++++++++---------
 src/AjaxUploader.tsx      |  22 +++++-
 src/concurrencyRequest.ts | 154 ++++++++++++++++++++++++++++++++++++++
 src/interface.tsx         |   7 ++
 src/request.ts            |  79 +++++++++++--------
 5 files changed, 261 insertions(+), 73 deletions(-)
 create mode 100644 src/concurrencyRequest.ts

diff --git a/README.md b/README.md
index 74906767..ac410e2c 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@ online example: https://upload.react-component.vercel.app/
 
 ## Feature
 
-* support IE11+, Chrome, Firefox, Safari
+- support IE11+, Chrome, Firefox, Safari
 
 ## install
 
@@ -54,29 +54,30 @@ React.render(<Upload />, container);
 
 ### props
 
-|name|type|default| description|
-|-----|---|--------|----|
-|name | string | file| file param post to server |
-|style | object | {}| root component inline style |
-|className | string | - | root component className |
-|disabled | boolean | false | whether disabled |
-|component | "div"|"span" | "span"| wrap component name |
-|action| string &#124; function(file): string &#124; Promise&lt;string&gt; | | form action url |
-|method | string | post | request method |
-|directory| boolean | false | support upload whole directory |
-|data| object/function(file) | | other data object to post or a function which returns a data object(a promise object which resolve a data object) |
-|headers| object | {} | http headers to post, available in modern browsers |
-|accept | string | | input accept attribute |
-|capture | string | | input capture attribute |
-|multiple | boolean | false | only support ie10+|
-|onStart | function| | start upload file |
-|onError| function| | error callback |
-|onSuccess | function | | success callback |
-|onProgress | function || progress callback, only for modern browsers|
-|beforeUpload| function |null| before upload check, return false or a rejected Promise will stop upload, only for modern browsers|
-|customRequest | function | null | provide an override for the default xhr behavior for additional customization|
-|withCredentials | boolean | false | ajax upload with cookie send |
-|openFileDialogOnClick | boolean | true | useful for drag only upload as it does not trigger on enter key or click event |
+| name | type | default | description |
+| --- | --- | --- | --- |
+| name | string | file | file param post to server |
+| style | object | {} | root component inline style |
+| className | string | - | root component className |
+| disabled | boolean | false | whether disabled |
+| component | "div" &#124; "span" | "span" | wrap component name |
+| action | string &#124; function(file): string &#124; Promise&lt;string&gt; |  | form action url |
+| method | string | post | request method |
+| directory | boolean | false | support upload whole directory |
+| data | object/function(file) |  | other data object to post or a function which returns a data object(a promise object which resolve a data object) |
+| headers | object | {} | http headers to post, available in modern browsers |
+| accept | string |  | input accept attribute |
+| capture | string |  | input capture attribute |
+| multiple | boolean | false | only support ie10+ |
+| concurrencyLimit | number &#124; undefined | undefined | asynchronously posts files with the concurrency limit |
+| onStart | function |  | start upload file |
+| onError | function |  | error callback |
+| onSuccess | function |  | success callback |
+| onProgress | function |  | progress callback, only for modern browsers |
+| beforeUpload | function | null | before upload check, return false or a rejected Promise will stop upload, only for modern browsers |
+| customRequest | function | null | provide an override for the default xhr behavior for additional customization |
+| withCredentials | boolean | false | ajax upload with cookie send |
+| openFileDialogOnClick | boolean | true | useful for drag only upload as it does not trigger on enter key or click event |
 
 #### onError arguments
 
@@ -88,9 +89,7 @@ React.render(<Upload />, container);
 
 1. `result`: response body
 2. `file`: upload file
-3. `xhr`: xhr header, only for modern browsers which support AJAX upload. since
-   2.4.0
-
+3. `xhr`: xhr header, only for modern browsers which support AJAX upload. since 2.4.0
 
 ### customRequest
 
@@ -98,16 +97,15 @@ Allows for advanced customization by overriding default behavior in AjaxUploader
 
 customRequest callback is passed an object with:
 
-* `onProgress: (event: { percent: number }): void`
-* `onError: (event: Error, body?: Object): void`
-* `onSuccess: (body: Object): void`
-* `data: Object`
-* `filename: String`
-* `file: File`
-* `withCredentials: Boolean`
-* `action: String`
-* `headers: Object`
-
+- `onProgress: (event: { percent: number }): void`
+- `onError: (event: Error, body?: Object): void`
+- `onSuccess: (body: Object): void`
+- `data: Object`
+- `filename: String`
+- `file: File`
+- `withCredentials: Boolean`
+- `action: String`
+- `headers: Object`
 
 ### methods
 
diff --git a/src/AjaxUploader.tsx b/src/AjaxUploader.tsx
index e88c291d..1cc0ef07 100644
--- a/src/AjaxUploader.tsx
+++ b/src/AjaxUploader.tsx
@@ -13,6 +13,7 @@ import type {
 import defaultRequest from './request';
 import traverseFileTree from './traverseFileTree';
 import getUid from './uid';
+import ConcurrencyRequester from './concurrencyRequest';
 
 interface ParsedFileInfo {
   origin: RcFile;
@@ -26,6 +27,8 @@ class AjaxUploader extends Component<UploadProps> {
 
   reqs: any = {};
 
+  private concurrencyRequester?: ConcurrencyRequester<any>;
+
   private fileInput: HTMLInputElement;
 
   private _isMounted: boolean;
@@ -111,10 +114,14 @@ class AjaxUploader extends Component<UploadProps> {
       return this.processFile(file, originFiles);
     });
 
+    const { onBatchStart, concurrencyLimit } = this.props;
+
+    if (concurrencyLimit) {
+      this.concurrencyRequester = new ConcurrencyRequester(concurrencyLimit);
+    }
+
     // Batch upload files
     Promise.all(postFiles).then(fileList => {
-      const { onBatchStart } = this.props;
-
       onBatchStart?.(fileList.map(({ origin, parsedFile }) => ({ file: origin, parsedFile })));
 
       fileList
@@ -122,6 +129,11 @@ class AjaxUploader extends Component<UploadProps> {
         .forEach(file => {
           this.post(file);
         });
+
+      // Asynchronously posts files with the concurrency limit.
+      if (this.concurrencyRequester) {
+        this.concurrencyRequester.send();
+      }
     });
   };
 
@@ -230,7 +242,11 @@ class AjaxUploader extends Component<UploadProps> {
     };
 
     onStart(origin);
-    this.reqs[uid] = request(requestOption);
+    if (this.concurrencyRequester) {
+      this.reqs[uid] = this.concurrencyRequester.append(requestOption);
+    } else {
+      this.reqs[uid] = request(requestOption);
+    }
   }
 
   reset() {
diff --git a/src/concurrencyRequest.ts b/src/concurrencyRequest.ts
new file mode 100644
index 00000000..dcef9da8
--- /dev/null
+++ b/src/concurrencyRequest.ts
@@ -0,0 +1,154 @@
+import { ConcurrencyRequestTask, UploadRequestOption } from './interface';
+import { prepareData, prepareXHR } from './request';
+
+/**
+ * Asynchronously processes an array of items with a concurrency limit.
+ *
+ * @template T - Type of the input items.
+ * @template U - Type of the result of the asynchronous task.
+ *
+ * @param {number} concurrencyLimit - The maximum number of asynchronous tasks to execute concurrently.
+ * @param {T[]} items - The array of items to process asynchronously.
+ * @param {(item: T) => Promise<U>} asyncTask - The asynchronous task to be performed on each item.
+ *
+ * @returns {Promise<U[]>} - A promise that resolves to an array of results from the asynchronous tasks.
+ */
+async function asyncPool<T, U>(
+  concurrencyLimit: number,
+  items: T[],
+  asyncTask: (item: T) => Promise<U>,
+): Promise<U[]> {
+  const tasks: Promise<U>[] = [];
+  const pendings: Promise<U>[] = [];
+
+  for (const item of items) {
+    const task = asyncTask(item);
+    tasks.push(task);
+
+    if (concurrencyLimit <= items.length) {
+      task.then(() => {
+        pendings.splice(pendings.indexOf(task), 1);
+      });
+      pendings.push(task);
+
+      if (pendings.length >= concurrencyLimit) {
+        await Promise.race(pendings);
+      }
+    }
+  }
+
+  return Promise.all(tasks);
+}
+
+type DataType = 'form' | 'blob' | 'string';
+
+/**
+ * Represents a class for handling concurrent requests with a specified concurrency limit.
+ *
+ * @template T - The type of data to be uploaded.
+ */
+export default class ConcurrencyRequester<T> {
+  /**
+   * The concurrency limit for handling requests simultaneously.
+   */
+  private concurrencyLimit: number;
+
+  /**
+   * An array to store the tasks for concurrent requests.
+   */
+  private tasks: ConcurrencyRequestTask[] = [];
+
+  /**
+   * The type of data to be sent in the request ('form', 'blob', or 'string').
+   */
+  private dataType: DataType;
+
+  /**
+   * Creates an instance of ConcurrencyRequester.
+   *
+   * @param {number} concurrencyLimit - The concurrency limit for handling requests simultaneously.
+   * @param {DataType} [dataType='form'] - The type of data to be sent in the request ('form', 'blob', or 'string').
+   */
+  constructor(concurrencyLimit: number, dataType: DataType = 'form') {
+    this.concurrencyLimit = concurrencyLimit;
+    this.dataType = dataType;
+  }
+
+  /**
+   * Prepares data based on the specified data type.
+   *
+   * @param {UploadRequestOption<T>} option - The upload request option.
+   * @returns {string | Blob | FormData} - The prepared data based on the specified data type.
+   * @private
+   */
+  private prepareData = (option: UploadRequestOption<T>): string | Blob | FormData => {
+    if (this.dataType === 'form') {
+      return prepareData(option);
+    }
+
+    return option.file;
+  };
+
+  /**
+   * Prepares a task for a concurrent request.
+   *
+   * @param {UploadRequestOption<T>} option - The upload request option.
+   * @returns {ConcurrencyRequestTask} - The prepared task for the concurrent request.
+   * @private
+   */
+  private prepare = (option: UploadRequestOption<T>): ConcurrencyRequestTask => {
+    const xhr = prepareXHR(option);
+
+    const data = this.prepareData(option);
+
+    const task: ConcurrencyRequestTask = { xhr, data };
+
+    xhr.onerror = function error(e) {
+      task.done?.();
+      xhr.onerror(e);
+    };
+
+    xhr.onload = function onload(e) {
+      task.done?.();
+      xhr.onload(e);
+    };
+
+    return task;
+  };
+
+  /**
+   * Appends a new upload request to the tasks array.
+   *
+   * @param {UploadRequestOption<T>} option - The upload request option.
+   * @returns {{ abort: () => void }} - An object with an `abort` function to cancel the request.
+   */
+  append = (option: UploadRequestOption<T>): { abort: () => void } => {
+    const task = this.prepare(option);
+
+    this.tasks.push(task);
+
+    return {
+      abort() {
+        task.xhr.abort();
+      },
+    };
+  };
+
+  /**
+   * Sends all the appended requests concurrently.
+   */
+  send = (): void => {
+    asyncPool(
+      this.concurrencyLimit,
+      this.tasks,
+      item =>
+        new Promise<void>(resolve => {
+          const xhr = item.xhr;
+
+          item.done = resolve;
+
+          xhr.send(item.data);
+        }),
+    );
+  };
+}
diff --git a/src/interface.tsx b/src/interface.tsx
index 9d02f276..b994693f 100644
--- a/src/interface.tsx
+++ b/src/interface.tsx
@@ -44,6 +44,7 @@ export interface UploadProps
     input?: React.CSSProperties;
   };
   hasControlInside?: boolean;
+  concurrencyLimit?: number;
 }
 
 export interface UploadProgressEvent extends Partial<ProgressEvent> {
@@ -76,3 +77,9 @@ export interface UploadRequestOption<T = any> {
 export interface RcFile extends File {
   uid: string;
 }
+
+export interface ConcurrencyRequestTask {
+  xhr: XMLHttpRequest;
+  data: File | FormData | string | Blob;
+  done?: () => void;
+}
diff --git a/src/request.ts b/src/request.ts
index 898847d0..904a24a2 100644
--- a/src/request.ts
+++ b/src/request.ts
@@ -22,7 +22,7 @@ function getBody(xhr: XMLHttpRequest) {
   }
 }
 
-export default function upload(option: UploadRequestOption) {
+export function prepareXHR(option: UploadRequestOption): XMLHttpRequest {
   // eslint-disable-next-line no-undef
   const xhr = new XMLHttpRequest();
 
@@ -35,6 +35,45 @@ export default function upload(option: UploadRequestOption) {
     };
   }
 
+  xhr.open(option.method, option.action, true);
+
+  // Has to be after `.open()`. See https://github.com/enyo/dropzone/issues/179
+  if (option.withCredentials && 'withCredentials' in xhr) {
+    xhr.withCredentials = true;
+  }
+
+  const headers = option.headers || {};
+
+  // when set headers['X-Requested-With'] = null , can close default XHR header
+  // see https://github.com/react-component/upload/issues/33
+  if (headers['X-Requested-With'] !== null) {
+    xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
+  }
+
+  Object.keys(headers).forEach(h => {
+    if (headers[h] !== null) {
+      xhr.setRequestHeader(h, headers[h]);
+    }
+  });
+
+  xhr.onerror = function error(e) {
+    option.onError(e);
+  };
+
+  xhr.onload = function onload(_) {
+    // allow success when 2xx status
+    // see https://github.com/react-component/upload/issues/34
+    if (xhr.status < 200 || xhr.status >= 300) {
+      return option.onError(getError(option, xhr), getBody(xhr));
+    }
+
+    return option.onSuccess(getBody(xhr), xhr);
+  };
+
+  return xhr;
+}
+
+export function prepareData(option: UploadRequestOption) {
   // eslint-disable-next-line no-undef
   const formData = new FormData();
 
@@ -62,40 +101,14 @@ export default function upload(option: UploadRequestOption) {
     formData.append(option.filename, option.file);
   }
 
-  xhr.onerror = function error(e) {
-    option.onError(e);
-  };
-
-  xhr.onload = function onload() {
-    // allow success when 2xx status
-    // see https://github.com/react-component/upload/issues/34
-    if (xhr.status < 200 || xhr.status >= 300) {
-      return option.onError(getError(option, xhr), getBody(xhr));
-    }
-
-    return option.onSuccess(getBody(xhr), xhr);
-  };
-
-  xhr.open(option.method, option.action, true);
-
-  // Has to be after `.open()`. See https://github.com/enyo/dropzone/issues/179
-  if (option.withCredentials && 'withCredentials' in xhr) {
-    xhr.withCredentials = true;
-  }
-
-  const headers = option.headers || {};
+  return formData;
+}
 
-  // when set headers['X-Requested-With'] = null , can close default XHR header
-  // see https://github.com/react-component/upload/issues/33
-  if (headers['X-Requested-With'] !== null) {
-    xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
-  }
+export default function upload(option: UploadRequestOption) {
+  const xhr = prepareXHR(option);
 
-  Object.keys(headers).forEach(h => {
-    if (headers[h] !== null) {
-      xhr.setRequestHeader(h, headers[h]);
-    }
-  });
+  // eslint-disable-next-line no-undef
+  const formData = prepareData(option);
 
   xhr.send(formData);
 

From bd547efb0b3e23368124f595e5c68315600b5bf9 Mon Sep 17 00:00:00 2001
From: Ryan Wang <thepoy@163.com>
Date: Sun, 14 Jan 2024 10:54:23 +0800
Subject: [PATCH 2/3] fix: a lint error

---
 src/request.ts | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/request.ts b/src/request.ts
index 904a24a2..9acd4ef2 100644
--- a/src/request.ts
+++ b/src/request.ts
@@ -60,6 +60,7 @@ export function prepareXHR(option: UploadRequestOption): XMLHttpRequest {
     option.onError(e);
   };
 
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
   xhr.onload = function onload(_) {
     // allow success when 2xx status
     // see https://github.com/react-component/upload/issues/34

From 89f3d6bf2ebf4c2861e109cc9abdcd5f87a6318b Mon Sep 17 00:00:00 2001
From: Ryan Wang <thepoy@163.com>
Date: Sun, 14 Jan 2024 12:12:13 +0800
Subject: [PATCH 3/3] refactor: improve XHR handling and type imports

- Use type imports in concurrencyRequest.ts for better clarity.
- Extract XHR load event handling into a separate function, onXHRLoad, in request.ts.
- Update references to onXHRLoad in both concurrencyRequest.ts and request.ts.
---
 src/concurrencyRequest.ts | 11 ++++++-----
 src/request.ts            | 20 ++++++++++++--------
 2 files changed, 18 insertions(+), 13 deletions(-)

diff --git a/src/concurrencyRequest.ts b/src/concurrencyRequest.ts
index dcef9da8..97bce5ba 100644
--- a/src/concurrencyRequest.ts
+++ b/src/concurrencyRequest.ts
@@ -1,5 +1,5 @@
-import { ConcurrencyRequestTask, UploadRequestOption } from './interface';
-import { prepareData, prepareXHR } from './request';
+import type { ConcurrencyRequestTask, UploadRequestOption } from './interface';
+import { onXHRLoad, prepareData, prepareXHR } from './request';
 
 /**
  * Asynchronously processes an array of items with a concurrency limit.
@@ -105,12 +105,13 @@ export default class ConcurrencyRequester<T> {
 
     xhr.onerror = function error(e) {
       task.done?.();
-      xhr.onerror(e);
+      option.onError(e);
     };
 
-    xhr.onload = function onload(e) {
+    xhr.onload = function onload() {
       task.done?.();
-      xhr.onload(e);
+
+      onXHRLoad(this, option);
     };
 
     return task;
diff --git a/src/request.ts b/src/request.ts
index 9acd4ef2..56fe726c 100644
--- a/src/request.ts
+++ b/src/request.ts
@@ -1,4 +1,4 @@
-import type { UploadRequestOption, UploadRequestError, UploadProgressEvent } from './interface';
+import type { UploadProgressEvent, UploadRequestError, UploadRequestOption } from './interface';
 
 function getError(option: UploadRequestOption, xhr: XMLHttpRequest) {
   const msg = `cannot ${option.method} ${option.action} ${xhr.status}'`;
@@ -22,6 +22,16 @@ function getBody(xhr: XMLHttpRequest) {
   }
 }
 
+export function onXHRLoad(xhr: XMLHttpRequest, option: UploadRequestOption) {
+  // allow success when 2xx status
+  // see https://github.com/react-component/upload/issues/34
+  if (xhr.status < 200 || xhr.status >= 300) {
+    return option.onError(getError(option, xhr), getBody(xhr));
+  }
+
+  return option.onSuccess(getBody(xhr), xhr);
+}
+
 export function prepareXHR(option: UploadRequestOption): XMLHttpRequest {
   // eslint-disable-next-line no-undef
   const xhr = new XMLHttpRequest();
@@ -62,13 +72,7 @@ export function prepareXHR(option: UploadRequestOption): XMLHttpRequest {
 
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   xhr.onload = function onload(_) {
-    // allow success when 2xx status
-    // see https://github.com/react-component/upload/issues/34
-    if (xhr.status < 200 || xhr.status >= 300) {
-      return option.onError(getError(option, xhr), getBody(xhr));
-    }
-
-    return option.onSuccess(getBody(xhr), xhr);
+    onXHRLoad(this, option);
   };
 
   return xhr;