diff --git a/README.md b/README.md index fd0e832..e04143b 100644 --- a/README.md +++ b/README.md @@ -97,10 +97,10 @@ Then open `http://localhost:8000`. ### Methods -| Name | Type | Description | -| ------- | ------------------------ | ------------------------------------ | -| `abort` | `(file: RcFile) => void` | Abort an active upload. | -| `retry` | `(file: RcFile) => void` | Retry an upload for a specific file. | +| Name | Type | Description | +| --- | --- | --- | +| `abort` | `(file: RcFile) => void` | Abort an active upload. | +| `retry` | `(file: RcFile) => Promise` | Retry an upload for a specific file. Resolves `true` if a request was started, otherwise `false` (file never uploaded, an upload is in flight, or unmounted). Reuses the fileInfo from the first upload (does not re-run `beforeUpload` / `action` / `data`), so only files that have been uploaded before can be retried. | ## Development diff --git a/README.zh-CN.md b/README.zh-CN.md index 8b35710..83df828 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -97,10 +97,10 @@ npm start ### 方法 -| 名称 | 类型 | 说明 | -| ------- | ------------------------ | -------------------- | -| `abort` | `(file: RcFile) => void` | 中止进行中的上传。 | -| `retry` | `(file: RcFile) => void` | 重试特定文件的上传。 | +| 名称 | 类型 | 说明 | +| --- | --- | --- | +| `abort` | `(file: RcFile) => void` | 中止进行中的上传。 | +| `retry` | `(file: RcFile) => Promise` | 重试特定文件的上传。发起了请求返回 `true`,否则返回 `false`(文件从未上传过、已有请求在飞、或组件已卸载)。复用首次上传的 fileInfo(不再执行 `beforeUpload` / 重算 `action` / `data`),因此只能重试已上传过的文件。 | ## 本地开发 diff --git a/src/AjaxUploader.tsx b/src/AjaxUploader.tsx index 2d4de93..7d34cbd 100644 --- a/src/AjaxUploader.tsx +++ b/src/AjaxUploader.tsx @@ -27,6 +27,8 @@ class AjaxUploader extends Component { reqs: Record = {}; + private fileInfoCache: Map = new Map(); + private fileInput: HTMLInputElement; private _isMounted: boolean; @@ -260,15 +262,17 @@ class AjaxUploader extends Component { }; }; - post({ data, origin, action, parsedFile }: ParsedFileInfo) { + post({ data, origin, action, parsedFile }: ParsedFileInfo): boolean { if (!this._isMounted) { - return; + return false; } - const { onStart, customRequest, name, headers, withCredentials, method } = this.props; - const { uid } = origin; + this.fileInfoCache.set(uid, { data, origin, action, parsedFile }); + + const { onStart, customRequest, name, headers, withCredentials, method } = this.props; + const request = customRequest || defaultRequest; const requestOption = { @@ -287,6 +291,7 @@ class AjaxUploader extends Component { const { onSuccess } = this.props; onSuccess?.(ret, parsedFile, xhr); + this.fileInfoCache.delete(uid); delete this.reqs[uid]; }, onError: (err: UploadRequestError, ret: any) => { @@ -298,21 +303,27 @@ class AjaxUploader extends Component { }; onStart(origin); - this.reqs[uid] = request(requestOption, { defaultRequest }); + this.reqs[uid] = {}; + try { + const handle = request(requestOption, { defaultRequest }); + if (this.reqs[uid]) { + this.reqs[uid] = handle || {}; + } + } catch (e) { + delete this.reqs[uid]; + return false; + } + return true; } - retry = (originFile: RcFile) => { + retry = async (originFile: RcFile): Promise => { const { uid } = originFile; - this.processFile(originFile, [originFile]) - .then(fileInfo => { - if (this.reqs[uid]) { - return; - } - if (fileInfo.parsedFile) { - this.post(fileInfo); - } - }) - .catch(() => {}); + const cachedFileInfo = this.fileInfoCache.get(uid); + if (!cachedFileInfo || this.reqs[uid]) { + return false; + } + + return this.post(cachedFileInfo); }; reset() { diff --git a/src/Upload.tsx b/src/Upload.tsx index 46f4dac..b67987f 100644 --- a/src/Upload.tsx +++ b/src/Upload.tsx @@ -30,8 +30,8 @@ class Upload extends Component { this.uploader.abort(file); } - retry(file: RcFile) { - this.uploader.retry(file); + retry(file: RcFile): Promise { + return this.uploader.retry(file); } saveUploader = (node: AjaxUpload) => { diff --git a/tests/uploader.spec.tsx b/tests/uploader.spec.tsx index 41f42fe..a1ccbf0 100644 --- a/tests/uploader.spec.tsx +++ b/tests/uploader.spec.tsx @@ -253,60 +253,58 @@ describe('uploader', () => { }, 100); }); - it('retry should make new request', done => { + it('retry should return false when the file was never uploaded', async () => { const uploadRef = React.createRef(); render(); const file = { - name: 'retry.png', + name: 'never-uploaded.png', toString() { return this.name; }, }; - const files = [file]; - (files as any).item = (i: number) => files[i]; const initialRequestCount = requests.length; - uploadRef.current.retry(file as any); + const result = await uploadRef.current.retry(file as any); - setTimeout(() => { - expect(requests.length).toBe(initialRequestCount + 1); - done(); - }, 100); + expect(result).toBe(false); + expect(requests.length).toBe(initialRequestCount); }); - it('retry should not make request when action rejects', done => { + it('retry should make a new request for a previously uploaded file', async () => { const uploadRef = React.createRef(); - render( - { - throw new Error('action error'); - }} - />, + const retryUploader = render( + {}} />, ); const file = { - name: 'reject.png', + name: 'retry.png', toString() { return this.name; }, }; + const files = [file]; + (files as any).item = (i: number) => files[i]; + + const input = retryUploader.container.querySelector('input')!; + fireEvent.change(input, { target: { files } }); + + await sleep(0); + requests[0].respond(400, {}, `error 400`); const initialRequestCount = requests.length; - uploadRef.current.retry(file as any); + const result = await uploadRef.current.retry(file as any); - setTimeout(() => { - expect(requests.length).toBe(initialRequestCount); - done(); - }, 100); + expect(result).toBe(true); + expect(requests.length).toBe(initialRequestCount + 1); + retryUploader.unmount(); }); - it('retry should not start overlapping request for the same file', done => { + it('retry should not start overlapping request for the same file', async () => { const uploadRef = React.createRef(); - render(); + const retryUploader = render( {}} />); const file = { name: 'overlap.png', @@ -315,21 +313,104 @@ describe('uploader', () => { }, }; (file as any).uid = 'fixed-overlap-uid'; + const files = [file]; + (files as any).item = (i: number) => files[i]; + + const input = retryUploader.container.querySelector('input')!; + fireEvent.change(input, { target: { files } }); + + await sleep(0); + requests[0].respond(400, {}, `error 400`); const initialRequestCount = requests.length; - uploadRef.current.retry(file as any); - uploadRef.current.retry(file as any); + const firstResult = uploadRef.current.retry(file as any); + const secondResult = uploadRef.current.retry(file as any); + const [first, second] = await Promise.all([firstResult, secondResult]); - setTimeout(() => { - expect(requests.length).toBe(initialRequestCount + 1); + expect(first).toBe(true); + expect(second).toBe(false); + expect(requests.length).toBe(initialRequestCount + 1); - expect(requests[requests.length - 1].aborted).toBeFalsy(); + expect(requests[requests.length - 1].aborted).toBeFalsy(); - uploadRef.current.abort(file); - expect(requests[requests.length - 1].aborted).toBe(true); - done(); - }, 100); + uploadRef.current.abort(file); + expect(requests[requests.length - 1].aborted).toBe(true); + + retryUploader.unmount(); + }); + + it('retry should not overlap when customRequest returns void', async () => { + const uploadRef = React.createRef(); + const retryUploader = render( + {}} customRequest={() => {}} />, + ); + + const file = { + name: 'void-retry.png', + toString() { + return this.name; + }, + }; + const files = [file]; + (files as any).item = (i: number) => files[i]; + + const input = retryUploader.container.querySelector('input')!; + // First upload caches the fileInfo; customRequest returns void so reqs[uid] + // is the `{}` placeholder, which never gets cleared by any callback. + fireEvent.change(input, { target: { files } }); + await sleep(0); + // Abort clears reqs[uid] (the `{}` has no .abort, so the call is skipped) + // while leaving fileInfoCache intact for retry to reuse. + uploadRef.current.abort(file); + + // Two concurrent retries race: the `|| {}` placeholder keeps reqs[uid] truthy + // after the first post(), so the second retry must be blocked. + const [first, second] = await Promise.all([ + uploadRef.current.retry(file as any), + uploadRef.current.retry(file as any), + ]); + + expect(first).toBe(true); + expect(second).toBe(false); + + retryUploader.unmount(); + }); + + it('retry should work after customRequest fails synchronously', async () => { + const uploadRef = React.createRef(); + const retryUploader = render( + {}} + customRequest={option => { + // Synchronously fail and return void. + option.onError(new Error('sync fail'), null); + }} + />, + ); + + const file = { + name: 'sync-fail.png', + toString() { + return this.name; + }, + }; + const files = [file]; + (files as any).item = (i: number) => files[i]; + + const input = retryUploader.container.querySelector('input')!; + fireEvent.change(input, { target: { files } }); + await sleep(0); + + // After a synchronous failure the in-flight marker must be cleared, + // otherwise retry would permanently see reqs[uid] and bail out. + const result = await uploadRef.current.retry(file as any); + + expect(result).toBe(true); + + retryUploader.unmount(); }); it('drag to upload', done => {