diff --git a/README.md b/README.md index fd0e832..27c767c 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) => void` | Retry an upload for a specific file. Only works for files that have been uploaded at least once (have cached info). Reuses the processed `beforeUpload`, `action`, and `data` from the first attempt. If no cache is found, fires `onError` with `UploadRetrySkipError`. Skips silently if the file has an active request. | ## Development diff --git a/README.zh-CN.md b/README.zh-CN.md index 8b35710..92dbd87 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) => void` | 重试特定文件的上传。仅对至少上传过一次(有缓存信息)的文件生效,复用首次处理的 `beforeUpload`、`action` 和 `data`。若缓存不存在,通过 `onError` 通知 `UploadRetrySkipError`;若文件已有活动请求则静默跳过。 | ## 本地开发 diff --git a/src/AjaxUploader.tsx b/src/AjaxUploader.tsx index 2d4de93..7f3cf51 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; @@ -157,6 +159,7 @@ class AjaxUploader extends Component { componentWillUnmount() { this._isMounted = false; this.abort(); + this.fileInfoCache.clear(); document.removeEventListener('paste', this.onFilePaste); } @@ -269,6 +272,8 @@ class AjaxUploader extends Component { const { uid } = origin; + this.fileInfoCache.set(uid, { data, origin, action, parsedFile }); + const request = customRequest || defaultRequest; const requestOption = { @@ -297,22 +302,42 @@ class AjaxUploader extends Component { }, }; - onStart(origin); - this.reqs[uid] = request(requestOption, { defaultRequest }); + this.reqs[uid] = {}; + try { + onStart(origin); + } catch (e) { + delete this.reqs[uid]; + throw e; + } + try { + const handle = request(requestOption, { defaultRequest }); + if (this.reqs[uid]) { + this.reqs[uid] = handle || {}; + } + } catch (e) { + delete this.reqs[uid]; + const { onError } = this.props; + const err = e instanceof Error ? e : new Error(String(e)); + onError?.(err as UploadRequestError, null, parsedFile); + } } retry = (originFile: RcFile) => { const { uid } = originFile; - this.processFile(originFile, [originFile]) - .then(fileInfo => { - if (this.reqs[uid]) { - return; - } - if (fileInfo.parsedFile) { - this.post(fileInfo); - } - }) - .catch(() => {}); + if (this.reqs[uid]) { + return; + } + + const cachedFileInfo = this.fileInfoCache.get(uid); + if (!cachedFileInfo) { + const { onError } = this.props; + const err = new Error('retry skipped: file was never uploaded') as UploadRequestError; + err.name = 'UploadRetrySkipError'; + onError?.(err, null, originFile); + return; + } + + this.post(cachedFileInfo); }; reset() { diff --git a/tests/uploader.spec.tsx b/tests/uploader.spec.tsx index 41f42fe..37c8d70 100644 --- a/tests/uploader.spec.tsx +++ b/tests/uploader.spec.tsx @@ -253,83 +253,408 @@ describe('uploader', () => { }, 100); }); - it('retry should make new request', done => { + it('retry skips and fires UploadRetrySkipError for never-uploaded file', async () => { const uploadRef = React.createRef(); - render(); + const onError = sinon.spy(); + const { unmount } = 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); + await sleep(0); - setTimeout(() => { - expect(requests.length).toBe(initialRequestCount + 1); - done(); - }, 100); + expect(requests.length).toBe(initialRequestCount); + expect(onError.calledOnce).toBeTruthy(); + expect(onError.firstCall.args[0]?.name).toBe('UploadRetrySkipError'); + unmount(); + }); + + it('reqs placeholder blocks concurrent retry and abort still works', async () => { + const ref1 = React.createRef(); + let onStartCalled = 0; + const { container: c1, unmount: u1 } = render( + { + onStartCalled += 1; + }} + />, + ); + + const file1 = { + name: 'dedup.png', + toString() { + return this.name; + }, + }; + const files1 = [file1]; + (files1 as any).item = (i: number) => files1[i]; + + fireEvent.change(c1.querySelector('input')!, { target: { files: files1 } }); + await sleep(0); + requests[0].respond(400, {}, `error 400`); + + const initialRequestCount = requests.length; + onStartCalled = 0; + + ref1.current.retry(file1 as any); + ref1.current.retry(file1 as any); + await sleep(0); + + expect(onStartCalled).toBe(1); + expect(requests.length).toBe(initialRequestCount + 1); + + ref1.current.abort(file1); + expect(requests[requests.length - 1].aborted).toBe(true); + u1(); + + const ref2 = React.createRef(); + let onStartCalled2 = 0; + const { container: c2, unmount: u2 } = render( + { + onStartCalled2 += 1; + ref2.current.retry(file); + }} + />, + ); + + const file2 = { + name: 'onstart-retry.png', + toString() { + return this.name; + }, + }; + const files2 = [file2]; + (files2 as any).item = (i: number) => files2[i]; + + fireEvent.change(c2.querySelector('input')!, { target: { files: files2 } }); + await sleep(0); + + expect(onStartCalled2).toBe(1); + u2(); }); - it('retry should not make request when action rejects', done => { + it('onStart throw should not leak reqs placeholder', async () => { + let shouldThrow = false; + let capturedUid: string | undefined; const uploadRef = React.createRef(); - render( + const { container, unmount } = render( { - throw new Error('action error'); + action="/test" + onStart={(file: any) => { + capturedUid = file.uid; + if (shouldThrow) { + throw new Error('onStart boom'); + } }} />, ); const file = { - name: 'reject.png', + name: 'onstart-throw.png', toString() { return this.name; }, }; + const files = [file]; + (files as any).item = (i: number) => files[i]; - const initialRequestCount = requests.length; + fireEvent.change(container.querySelector('input')!, { target: { files } }); + await sleep(0); + requests[0].respond(400, {}, `error 400`); + + const ajaxRef = (uploadRef as any).current?.uploader; + shouldThrow = true; + expect(() => { + uploadRef.current.retry(file as any); + }).toThrow('onStart boom'); + + expect(capturedUid).toBeDefined(); + expect(ajaxRef.reqs[capturedUid!]).toBeUndefined(); + + shouldThrow = false; uploadRef.current.retry(file as any); + await sleep(0); + expect(requests.length).toBe(2); + unmount(); + }); - setTimeout(() => { - expect(requests.length).toBe(initialRequestCount); - done(); - }, 100); + it('retry reuses cache: no beforeUpload/action/data re-run, works after success and throw', async () => { + const beforeUpload = sinon.spy((file: any) => file); + const action = sinon.stub().returns('/retry-action'); + const data = sinon.stub().returns({ key: 'value' }); + const ref1 = React.createRef(); + const { container: c1, unmount: u1 } = render( + , + ); + + const file1 = { + name: 'no-reprocess.png', + toString() { + return this.name; + }, + }; + const files1 = [file1]; + (files1 as any).item = (i: number) => files1[i]; + + fireEvent.change(c1.querySelector('input')!, { target: { files: files1 } }); + await sleep(0); + requests[0].respond(400, {}, `error 400`); + + const callCountBefore = beforeUpload.callCount; + const actionCallCount = action.callCount; + const dataCallCount = data.callCount; + + ref1.current.retry(file1 as any); + await sleep(0); + + expect(beforeUpload.callCount).toBe(callCountBefore); + expect(action.callCount).toBe(actionCallCount); + expect(data.callCount).toBe(dataCallCount); + u1(); + + const ref2 = React.createRef(); + const { container: c2, unmount: u2 } = render(); + + const file2 = { + name: 'success-retry.png', + toString() { + return this.name; + }, + }; + const files2 = [file2]; + (files2 as any).item = (i: number) => files2[i]; + + fireEvent.change(c2.querySelector('input')!, { target: { files: files2 } }); + await sleep(0); + requests[requests.length - 1].respond(200, {}, `["","ok"]`); + + const countAfterSuccess = requests.length; + ref2.current.retry(file2 as any); + await sleep(0); + + expect(requests.length).toBe(countAfterSuccess + 1); + u2(); + + let throwOnce = true; + const ref3 = React.createRef(); + const throwOnError = sinon.spy(); + let retryOnStartCalled = 0; + const { container: c3, unmount: u3 } = render( + { + retryOnStartCalled += 1; + }} + customRequest={() => { + if (throwOnce) { + throwOnce = false; + throw new Error('sync throw'); + } + }} + onError={throwOnError} + />, + ); + + const file3 = { + name: 'throw-retry.png', + toString() { + return this.name; + }, + }; + const files3 = [file3]; + (files3 as any).item = (i: number) => files3[i]; + + const initialRequestCount = requests.length; + fireEvent.change(c3.querySelector('input')!, { target: { files: files3 } }); + await sleep(0); + + expect(throwOnError.calledOnce).toBeTruthy(); + expect(throwOnError.firstCall.args[0]?.message).toBe('sync throw'); + expect(requests.length).toBe(initialRequestCount); + + retryOnStartCalled = 0; + ref3.current.retry(file3 as any); + await sleep(0); + + expect(throwOnError.callCount).toBe(1); + expect(retryOnStartCalled).toBe(1); + u3(); + }); + + it('customRequest edge cases: void return and sync onError', async () => { + const ref1 = React.createRef(); + let onStartCallCount = 0; + const { container: c1, unmount: u1 } = render( + { + onStartCallCount += 1; + }} + customRequest={() => {}} + />, + ); + + const file1 = { + name: 'void-custom.png', + toString() { + return this.name; + }, + }; + const files1 = [file1]; + (files1 as any).item = (i: number) => files1[i]; + + fireEvent.change(c1.querySelector('input')!, { target: { files: files1 } }); + await sleep(0); + + ref1.current.abort(file1 as any); + + onStartCallCount = 0; + + ref1.current.retry(file1 as any); + ref1.current.retry(file1 as any); + await sleep(0); + + expect(onStartCallCount).toBe(1); + u1(); + + const ref2 = React.createRef(); + let retryOnStartCalled = 0; + const { container: c2, unmount: u2 } = render( + { + retryOnStartCalled += 1; + }} + customRequest={(options: any) => { + options.onError(new Error('sync error')); + }} + />, + ); + + const file2 = { + name: 'sync-onerror.png', + toString() { + return this.name; + }, + }; + const files2 = [file2]; + (files2 as any).item = (i: number) => files2[i]; + + fireEvent.change(c2.querySelector('input')!, { target: { files: files2 } }); + await sleep(0); + + expect(retryOnStartCalled).toBe(1); + + ref2.current.retry(file2 as any); + await sleep(0); + + expect(retryOnStartCalled).toBe(2); + u2(); + }); + + it('catch normalizes non-Error throw, retry works after sync onSuccess', async () => { + const mkFile = (name: string) => { + const f = { + name, + toString() { + return this.name; + }, + }; + return f; + }; + const mkFiles = (f: any) => { + const a = [f]; + (a as any).item = (i: number) => a[i]; + return a; + }; + + // Path 1: string throw → normalized to Error + const onError1 = sinon.spy(); + const { container: c1, unmount: u1 } = render( + { + throw 'string error'; + }} + />, + ); + fireEvent.change(c1.querySelector('input')!, { target: { files: mkFiles(mkFile('s.png')) } }); + await sleep(0); + expect(onError1.calledOnce).toBeTruthy(); + expect(onError1.firstCall.args[0]).toBeInstanceOf(Error); + expect(onError1.firstCall.args[0].message).toBe('string error'); + u1(); + + // Path 2: sync onSuccess → reqs cleaned, retry works + const ref = React.createRef(); + let onStartCalled = 0; + const { container: c2, unmount: u2 } = render( + { + onStartCalled += 1; + }} + customRequest={(opts: any) => { + opts.onSuccess({}, null); + }} + />, + ); + const okFile = mkFile('ok.png'); + fireEvent.change(c2.querySelector('input')!, { target: { files: mkFiles(okFile) } }); + await sleep(0); + expect(onStartCalled).toBe(1); + ref.current.retry(okFile as any); + await sleep(0); + expect(onStartCalled).toBe(2); + u2(); }); - it('retry should not start overlapping request for the same file', done => { + it('retry does not send request after unmount', async () => { const uploadRef = React.createRef(); - render(); + const { container, unmount } = render(); const file = { - name: 'overlap.png', + name: 'unmount-test.png', toString() { return this.name; }, }; - (file as any).uid = 'fixed-overlap-uid'; + const files = [file]; + (files as any).item = (i: number) => files[i]; + + fireEvent.change(container.querySelector('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 ajaxRef = (uploadRef as any).current?.uploader; + const ajaxRetry = ajaxRef.retry; - setTimeout(() => { - expect(requests.length).toBe(initialRequestCount + 1); + unmount(); - expect(requests[requests.length - 1].aborted).toBeFalsy(); + ajaxRetry.call(ajaxRef, file as any); + await sleep(0); - uploadRef.current.abort(file); - expect(requests[requests.length - 1].aborted).toBe(true); - done(); - }, 100); + expect(requests.length).toBe(initialRequestCount); }); it('drag to upload', done => {