From e7f4b843d2527c7351f664336d37871e9aacc77d Mon Sep 17 00:00:00 2001 From: lh01217311 Date: Wed, 30 Sep 2026 14:17:01 +0800 Subject: [PATCH 1/5] fix: use fileInfoCache for retry instead of processFile, add sync reqs placeholder and cache cleanup --- src/AjaxUploader.tsx | 41 ++++++++++++++------ tests/uploader.spec.tsx | 83 ++++++++++++++++++++++++++--------------- 2 files changed, 82 insertions(+), 42 deletions(-) diff --git a/src/AjaxUploader.tsx b/src/AjaxUploader.tsx index 2d4de93..0c40a20 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 = { @@ -287,6 +292,8 @@ 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 +305,33 @@ 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]; + } } 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..20c1f50 100644 --- a/tests/uploader.spec.tsx +++ b/tests/uploader.spec.tsx @@ -253,9 +253,9 @@ describe('uploader', () => { }, 100); }); - it('retry should make new request', done => { + it('retry should make new request for a previously uploaded file', async () => { const uploadRef = React.createRef(); - render(); + const { container, unmount } = render(); const file = { name: 'retry.png', @@ -266,47 +266,41 @@ describe('uploader', () => { const files = [file]; (files as any).item = (i: number) => files[i]; - const initialRequestCount = requests.length; + const input = 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); + await sleep(0); - setTimeout(() => { - expect(requests.length).toBe(initialRequestCount + 1); - done(); - }, 100); + expect(requests.length).toBe(initialRequestCount + 1); + unmount(); }); - it('retry should not make request when action rejects', done => { + it('retry should not make request for a file that was never uploaded', async () => { const uploadRef = React.createRef(); - render( - { - throw new Error('action error'); - }} - />, - ); + const { unmount } = render(); const file = { - name: 'reject.png', + name: 'never-uploaded.png', toString() { return this.name; }, }; const initialRequestCount = requests.length; - uploadRef.current.retry(file as any); + await sleep(0); - setTimeout(() => { - expect(requests.length).toBe(initialRequestCount); - done(); - }, 100); + expect(requests.length).toBe(initialRequestCount); + 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 { container, unmount } = render(); const file = { name: 'overlap.png', @@ -315,21 +309,48 @@ describe('uploader', () => { }, }; (file as any).uid = 'fixed-overlap-uid'; + const files = [file]; + (files as any).item = (i: number) => files[i]; + + // First upload populates cache; the fixed uid is reused by retry. + const input = 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); + await sleep(0); - setTimeout(() => { - expect(requests.length).toBe(initialRequestCount + 1); + 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); + unmount(); + }); + + it('retry should fire onError for a file that was never uploaded', async () => { + const uploadRef = React.createRef(); + const onError = sinon.spy(); + const { unmount } = render(); + + const file = { + name: 'never-uploaded-error.png', + toString() { + return this.name; + }, + }; + + uploadRef.current.retry(file as any); + await sleep(0); + + expect(onError.calledOnce).toBeTruthy(); + expect(onError.firstCall.args[0]?.name).toBe('UploadRetrySkipError'); + unmount(); }); it('drag to upload', done => { From 39b66b46d99f5dc1efbf8e355951bc8384d161e5 Mon Sep 17 00:00:00 2001 From: lh01217311 Date: Wed, 30 Sep 2026 15:23:27 +0800 Subject: [PATCH 2/5] fix: use fileInfoCache for retry, sync reqs placeholder, onError on throw; add tests --- src/AjaxUploader.tsx | 7 +- tests/uploader.spec.tsx | 175 ++++++++++++++++++++++++++++++++++------ 2 files changed, 155 insertions(+), 27 deletions(-) diff --git a/src/AjaxUploader.tsx b/src/AjaxUploader.tsx index 0c40a20..cacd36e 100644 --- a/src/AjaxUploader.tsx +++ b/src/AjaxUploader.tsx @@ -304,8 +304,8 @@ class AjaxUploader extends Component { }, }; - onStart(origin); this.reqs[uid] = {}; + onStart(origin); try { const handle = request(requestOption, { defaultRequest }); if (this.reqs[uid]) { @@ -313,6 +313,11 @@ class AjaxUploader extends Component { } } catch (e) { delete this.reqs[uid]; + this.fileInfoCache.delete(uid); + const { onError } = this.props; + const err = e as UploadRequestError; + err.name = err.name || 'UploadRequestError'; + onError?.(err, null, parsedFile); } } diff --git a/tests/uploader.spec.tsx b/tests/uploader.spec.tsx index 20c1f50..9b438f3 100644 --- a/tests/uploader.spec.tsx +++ b/tests/uploader.spec.tsx @@ -253,32 +253,6 @@ describe('uploader', () => { }, 100); }); - it('retry should make new request for a previously uploaded file', async () => { - const uploadRef = React.createRef(); - const { container, unmount } = render(); - - const file = { - name: 'retry.png', - toString() { - return this.name; - }, - }; - const files = [file]; - (files as any).item = (i: number) => files[i]; - - const input = 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); - await sleep(0); - - expect(requests.length).toBe(initialRequestCount + 1); - unmount(); - }); - it('retry should not make request for a file that was never uploaded', async () => { const uploadRef = React.createRef(); const { unmount } = render(); @@ -352,6 +326,155 @@ describe('uploader', () => { expect(onError.firstCall.args[0]?.name).toBe('UploadRetrySkipError'); unmount(); }); + it('customRequest sync throw should notify via onError', async () => { + const syncThrowRef = React.createRef(); + const onError = sinon.spy(); + const { container, unmount } = render( + { + throw new Error('sync boom'); + }} + onError={onError} + />, + ); + + const file = { + name: 'sync-throw.png', + toString() { + return this.name; + }, + }; + const files = [file]; + (files as any).item = (i: number) => files[i]; + + const input = container.querySelector('input')!; + const initialRequestCount = requests.length; + fireEvent.change(input, { target: { files } }); + await sleep(0); + + expect(onError.calledOnce).toBeTruthy(); + expect(onError.firstCall.args[0]?.message).toBe('sync boom'); + // No real XHR was created since customRequest threw before it could call defaultRequest + expect(requests.length).toBe(initialRequestCount); + unmount(); + }); + + it('retry should not re-run beforeUpload, action, or data', async () => { + const beforeUpload = sinon.spy((file: any) => file); + const action = sinon.stub().returns('/retry-action'); + const data = sinon.stub().returns({ key: 'value' }); + const uploadRef = React.createRef(); + const { container, unmount } = render( + , + ); + + const file = { + name: 'no-reprocess.png', + toString() { + return this.name; + }, + }; + (file as any).uid = 'fixed-uid-no-reprocess'; + const files = [file]; + (files as any).item = (i: number) => files[i]; + + const input = container.querySelector('input')!; + fireEvent.change(input, { target: { files } }); + await sleep(0); + requests[0].respond(400, {}, `error 400`); + + const callCountBefore = beforeUpload.callCount; + const actionCallCount = action.callCount; + const dataCallCount = data.callCount; + + uploadRef.current.retry(file as any); + await sleep(0); + + // beforeUpload, action, data should NOT be called again during retry + expect(beforeUpload.callCount).toBe(callCountBefore); + expect(action.callCount).toBe(actionCallCount); + expect(data.callCount).toBe(dataCallCount); + + expect(requests.length).toBe(2); + unmount(); + }); + + it('cache cleared on success and sync throw, retry fires UploadRetrySkipError', async () => { + // --- Path 1: onSuccess clears cache --- + const successRef = React.createRef(); + const successOnError = sinon.spy(); + const { container: c1, unmount: u1 } = render( + , + ); + + const file1 = { + name: 'success-cleanup.png', + toString() { + return this.name; + }, + }; + (file1 as any).uid = 'uid-success-path'; + const files1 = [file1]; + (files1 as any).item = (i: number) => files1[i]; + + fireEvent.change(c1.querySelector('input')!, { target: { files: files1 } }); + await sleep(0); + requests[requests.length - 1].respond(200, {}, `["","ok"]`); + + const countAfterSuccess = requests.length; + successRef.current.retry(file1 as any); + await sleep(0); + + expect(requests.length).toBe(countAfterSuccess); + expect(successOnError.calledOnce).toBeTruthy(); + expect(successOnError.firstCall.args[0]?.name).toBe('UploadRetrySkipError'); + u1(); + + // --- Path 2: request sync throw clears cache --- + let throwOnce = true; + const throwRef = React.createRef(); + const throwOnError = sinon.spy(); + const { container: c2, unmount: u2 } = render( + { + if (throwOnce) { + throwOnce = false; + throw new Error('sync throw'); + } + }} + onError={throwOnError} + />, + ); + + const file2 = { + name: 'throw-cleanup.png', + toString() { + return this.name; + }, + }; + (file2 as any).uid = 'uid-throw-path'; + const files2 = [file2]; + (files2 as any).item = (i: number) => files2[i]; + + fireEvent.change(c2.querySelector('input')!, { target: { files: files2 } }); + await sleep(0); + + expect(throwOnError.calledOnce).toBeTruthy(); + expect(throwOnError.firstCall.args[0]?.message).toBe('sync throw'); + + const countAfterThrow = requests.length; + throwRef.current.retry(file2 as any); + await sleep(0); + + expect(requests.length).toBe(countAfterThrow); + expect(throwOnError.callCount).toBe(2); + expect(throwOnError.secondCall.args[0]?.name).toBe('UploadRetrySkipError'); + u2(); + }); it('drag to upload', done => { const input = uploader.container.querySelector('input')!; From 82d9fe01c2c5dd9181e80a1daa9f0bee86c397af Mon Sep 17 00:00:00 2001 From: lh01217311 Date: Wed, 30 Sep 2026 15:57:52 +0800 Subject: [PATCH 3/5] fix: wrap onStart in try/catch to prevent reqs placeholder leak, and merge retry tests (8->5) --- src/AjaxUploader.tsx | 7 ++- tests/uploader.spec.tsx | 118 +++++++++++++++++++++++----------------- 2 files changed, 73 insertions(+), 52 deletions(-) diff --git a/src/AjaxUploader.tsx b/src/AjaxUploader.tsx index cacd36e..675022c 100644 --- a/src/AjaxUploader.tsx +++ b/src/AjaxUploader.tsx @@ -305,7 +305,12 @@ class AjaxUploader extends Component { }; this.reqs[uid] = {}; - onStart(origin); + try { + onStart(origin); + } catch (e) { + delete this.reqs[uid]; + throw e; + } try { const handle = request(requestOption, { defaultRequest }); if (this.reqs[uid]) { diff --git a/tests/uploader.spec.tsx b/tests/uploader.spec.tsx index 9b438f3..5c90fba 100644 --- a/tests/uploader.spec.tsx +++ b/tests/uploader.spec.tsx @@ -253,9 +253,10 @@ describe('uploader', () => { }, 100); }); - it('retry should not make request for a file that was never uploaded', async () => { + it('retry skips and fires UploadRetrySkipError for never-uploaded file', async () => { const uploadRef = React.createRef(); - const { unmount } = render(); + const onError = sinon.spy(); + const { unmount } = render(); const file = { name: 'never-uploaded.png', @@ -268,96 +269,110 @@ describe('uploader', () => { uploadRef.current.retry(file as any); await sleep(0); + // No XHR created, and onError receives UploadRetrySkipError expect(requests.length).toBe(initialRequestCount); + expect(onError.calledOnce).toBeTruthy(); + expect(onError.firstCall.args[0]?.name).toBe('UploadRetrySkipError'); unmount(); }); - it('retry should not start overlapping request for the same file', async () => { + it('double retry only starts one request and abort still works', async () => { const uploadRef = React.createRef(); - const { container, unmount } = render(); + let onStartCalled = 0; + const { container, unmount } = render( + { + onStartCalled += 1; + }} + />, + ); const file = { - name: 'overlap.png', + name: 'dedup.png', toString() { return this.name; }, }; - (file as any).uid = 'fixed-overlap-uid'; + (file as any).uid = 'fixed-dedup-uid'; const files = [file]; (files as any).item = (i: number) => files[i]; - // First upload populates cache; the fixed uid is reused by retry. - const input = container.querySelector('input')!; - fireEvent.change(input, { target: { files } }); + fireEvent.change(container.querySelector('input')!, { target: { files } }); await sleep(0); requests[0].respond(400, {}, `error 400`); const initialRequestCount = requests.length; + onStartCalled = 0; + // reqs placeholder is set synchronously before onStart/request, + // so a second retry in the same tick is blocked uploadRef.current.retry(file as any); uploadRef.current.retry(file as any); await sleep(0); + // Only one request created, onStart called only once + expect(onStartCalled).toBe(1); expect(requests.length).toBe(initialRequestCount + 1); - expect(requests[requests.length - 1].aborted).toBeFalsy(); + // Abort still works uploadRef.current.abort(file); expect(requests[requests.length - 1].aborted).toBe(true); unmount(); }); - it('retry should fire onError for a file that was never uploaded', async () => { + it('onStart throw should not leak reqs placeholder', async () => { const uploadRef = React.createRef(); - const onError = sinon.spy(); - const { unmount } = render(); - - const file = { - name: 'never-uploaded-error.png', - toString() { - return this.name; - }, - }; - - uploadRef.current.retry(file as any); - await sleep(0); - - expect(onError.calledOnce).toBeTruthy(); - expect(onError.firstCall.args[0]?.name).toBe('UploadRetrySkipError'); - unmount(); - }); - it('customRequest sync throw should notify via onError', async () => { - const syncThrowRef = React.createRef(); - const onError = sinon.spy(); + const innerRef = React.createRef(); const { container, unmount } = render( { - throw new Error('sync boom'); + ref={(node: any) => { + (uploadRef as any).current = node; + if (node?.uploader) { + (innerRef as any).current = node.uploader; + } }} - onError={onError} + action="/test" />, ); const file = { - name: 'sync-throw.png', + name: 'onstart-throw.png', toString() { return this.name; }, }; + (file as any).uid = 'fixed-onstart-throw-uid'; const files = [file]; (files as any).item = (i: number) => files[i]; - const input = container.querySelector('input')!; - const initialRequestCount = requests.length; - fireEvent.change(input, { target: { files } }); + fireEvent.change(container.querySelector('input')!, { target: { files } }); await sleep(0); + requests[0].respond(400, {}, `error 400`); - expect(onError.calledOnce).toBeTruthy(); - expect(onError.firstCall.args[0]?.message).toBe('sync boom'); - // No real XHR was created since customRequest threw before it could call defaultRequest - expect(requests.length).toBe(initialRequestCount); + const ajaxRef = (uploadRef as any).current?.uploader; + + // Simulate onStart throwing + const originalOnStart = ajaxRef.props.onStart; + let throwCount = 0; + ajaxRef.props.onStart = () => { + throwCount += 1; + throw new Error('onStart boom'); + }; + + // Retry: onStart throws, reqs[uid] placeholder is cleaned up + expect(() => { + uploadRef.current.retry(file as any); + }).toThrow('onStart boom'); + expect(ajaxRef.reqs['fixed-onstart-throw-uid']).toBeUndefined(); + + // Restore onStart so second retry works + ajaxRef.props.onStart = originalOnStart; + uploadRef.current.retry(file as any); + await sleep(0); + expect(requests.length).toBe(2); unmount(); }); @@ -392,17 +407,15 @@ describe('uploader', () => { uploadRef.current.retry(file as any); await sleep(0); - // beforeUpload, action, data should NOT be called again during retry expect(beforeUpload.callCount).toBe(callCountBefore); expect(action.callCount).toBe(actionCallCount); expect(data.callCount).toBe(dataCallCount); - expect(requests.length).toBe(2); unmount(); }); - it('cache cleared on success and sync throw, retry fires UploadRetrySkipError', async () => { - // --- Path 1: onSuccess clears cache --- + it('cache cleared: success or sync throw blocks subsequent retry', async () => { + // --- Path 1: onSuccess clears cache, retry fires UploadRetrySkipError --- const successRef = React.createRef(); const successOnError = sinon.spy(); const { container: c1, unmount: u1 } = render( @@ -432,7 +445,7 @@ describe('uploader', () => { expect(successOnError.firstCall.args[0]?.name).toBe('UploadRetrySkipError'); u1(); - // --- Path 2: request sync throw clears cache --- + // --- Path 2: customRequest sync throw clears cache, onError notified, retry fires UploadRetrySkipError --- let throwOnce = true; const throwRef = React.createRef(); const throwOnError = sinon.spy(); @@ -460,17 +473,20 @@ describe('uploader', () => { const files2 = [file2]; (files2 as any).item = (i: number) => files2[i]; + const initialRequestCount = requests.length; fireEvent.change(c2.querySelector('input')!, { target: { files: files2 } }); await sleep(0); + // customRequest sync throw: onError notified, no real XHR created expect(throwOnError.calledOnce).toBeTruthy(); expect(throwOnError.firstCall.args[0]?.message).toBe('sync throw'); + expect(requests.length).toBe(initialRequestCount); - const countAfterThrow = requests.length; + // Cache cleared after throw, retry fires UploadRetrySkipError throwRef.current.retry(file2 as any); await sleep(0); - expect(requests.length).toBe(countAfterThrow); + expect(requests.length).toBe(initialRequestCount); expect(throwOnError.callCount).toBe(2); expect(throwOnError.secondCall.args[0]?.name).toBe('UploadRetrySkipError'); u2(); From c087fe87d6afb8d475f69825df9c85ff9ec4834d Mon Sep 17 00:00:00 2001 From: lh01217311 Date: Wed, 30 Sep 2026 17:06:14 +0800 Subject: [PATCH 4/5] fix: do not actively delete fileInfoCache, fix onStart test uid assertion --- src/AjaxUploader.tsx | 3 - tests/uploader.spec.tsx | 280 ++++++++++++++++++++++++++++------------ 2 files changed, 194 insertions(+), 89 deletions(-) diff --git a/src/AjaxUploader.tsx b/src/AjaxUploader.tsx index 675022c..18a0c33 100644 --- a/src/AjaxUploader.tsx +++ b/src/AjaxUploader.tsx @@ -292,8 +292,6 @@ 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) => { @@ -318,7 +316,6 @@ class AjaxUploader extends Component { } } catch (e) { delete this.reqs[uid]; - this.fileInfoCache.delete(uid); const { onError } = this.props; const err = e as UploadRequestError; err.name = err.name || 'UploadRequestError'; diff --git a/tests/uploader.spec.tsx b/tests/uploader.spec.tsx index 5c90fba..21a8b76 100644 --- a/tests/uploader.spec.tsx +++ b/tests/uploader.spec.tsx @@ -269,19 +269,18 @@ describe('uploader', () => { uploadRef.current.retry(file as any); await sleep(0); - // No XHR created, and onError receives UploadRetrySkipError expect(requests.length).toBe(initialRequestCount); expect(onError.calledOnce).toBeTruthy(); expect(onError.firstCall.args[0]?.name).toBe('UploadRetrySkipError'); unmount(); }); - it('double retry only starts one request and abort still works', async () => { - const uploadRef = React.createRef(); + it('reqs placeholder blocks concurrent retry and abort still works', async () => { + const ref1 = React.createRef(); let onStartCalled = 0; - const { container, unmount } = render( + const { container: c1, unmount: u1 } = render( { onStartCalled += 1; @@ -289,52 +288,76 @@ describe('uploader', () => { />, ); - const file = { + const file1 = { name: 'dedup.png', toString() { return this.name; }, }; - (file as any).uid = 'fixed-dedup-uid'; - const files = [file]; - (files as any).item = (i: number) => files[i]; + const files1 = [file1]; + (files1 as any).item = (i: number) => files1[i]; - fireEvent.change(container.querySelector('input')!, { target: { files } }); + fireEvent.change(c1.querySelector('input')!, { target: { files: files1 } }); await sleep(0); requests[0].respond(400, {}, `error 400`); const initialRequestCount = requests.length; onStartCalled = 0; - // reqs placeholder is set synchronously before onStart/request, - // so a second retry in the same tick is blocked - uploadRef.current.retry(file as any); - uploadRef.current.retry(file as any); + ref1.current.retry(file1 as any); + ref1.current.retry(file1 as any); await sleep(0); - // Only one request created, onStart called only once expect(onStartCalled).toBe(1); expect(requests.length).toBe(initialRequestCount + 1); - expect(requests[requests.length - 1].aborted).toBeFalsy(); - // Abort still works - uploadRef.current.abort(file); + ref1.current.abort(file1); expect(requests[requests.length - 1].aborted).toBe(true); - unmount(); + 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('onStart throw should not leak reqs placeholder', async () => { + let shouldThrow = false; + let capturedUid: string | undefined; const uploadRef = React.createRef(); - const innerRef = React.createRef(); const { container, unmount } = render( { - (uploadRef as any).current = node; - if (node?.uploader) { - (innerRef as any).current = node.uploader; + ref={uploadRef} + action="/test" + onStart={(file: any) => { + capturedUid = file.uid; + if (shouldThrow) { + throw new Error('onStart boom'); } }} - action="/test" />, ); @@ -344,7 +367,6 @@ describe('uploader', () => { return this.name; }, }; - (file as any).uid = 'fixed-onstart-throw-uid'; const files = [file]; (files as any).item = (i: number) => files[i]; @@ -354,49 +376,40 @@ describe('uploader', () => { const ajaxRef = (uploadRef as any).current?.uploader; - // Simulate onStart throwing - const originalOnStart = ajaxRef.props.onStart; - let throwCount = 0; - ajaxRef.props.onStart = () => { - throwCount += 1; - throw new Error('onStart boom'); - }; - - // Retry: onStart throws, reqs[uid] placeholder is cleaned up + shouldThrow = true; expect(() => { uploadRef.current.retry(file as any); }).toThrow('onStart boom'); - expect(ajaxRef.reqs['fixed-onstart-throw-uid']).toBeUndefined(); - // Restore onStart so second retry works - ajaxRef.props.onStart = originalOnStart; + 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(); }); - it('retry should not re-run beforeUpload, action, or data', async () => { + 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 uploadRef = React.createRef(); - const { container, unmount } = render( - , + const ref1 = React.createRef(); + const { container: c1, unmount: u1 } = render( + , ); - const file = { + const file1 = { name: 'no-reprocess.png', toString() { return this.name; }, }; - (file as any).uid = 'fixed-uid-no-reprocess'; - const files = [file]; - (files as any).item = (i: number) => files[i]; + const files1 = [file1]; + (files1 as any).item = (i: number) => files1[i]; - const input = container.querySelector('input')!; - fireEvent.change(input, { target: { files } }); + fireEvent.change(c1.querySelector('input')!, { target: { files: files1 } }); await sleep(0); requests[0].respond(400, {}, `error 400`); @@ -404,55 +417,48 @@ describe('uploader', () => { const actionCallCount = action.callCount; const dataCallCount = data.callCount; - uploadRef.current.retry(file as any); + ref1.current.retry(file1 as any); await sleep(0); expect(beforeUpload.callCount).toBe(callCountBefore); expect(action.callCount).toBe(actionCallCount); expect(data.callCount).toBe(dataCallCount); - expect(requests.length).toBe(2); - unmount(); - }); + u1(); - it('cache cleared: success or sync throw blocks subsequent retry', async () => { - // --- Path 1: onSuccess clears cache, retry fires UploadRetrySkipError --- - const successRef = React.createRef(); - const successOnError = sinon.spy(); - const { container: c1, unmount: u1 } = render( - , - ); + const ref2 = React.createRef(); + const { container: c2, unmount: u2 } = render(); - const file1 = { - name: 'success-cleanup.png', + const file2 = { + name: 'success-retry.png', toString() { return this.name; }, }; - (file1 as any).uid = 'uid-success-path'; - const files1 = [file1]; - (files1 as any).item = (i: number) => files1[i]; + const files2 = [file2]; + (files2 as any).item = (i: number) => files2[i]; - fireEvent.change(c1.querySelector('input')!, { target: { files: files1 } }); + fireEvent.change(c2.querySelector('input')!, { target: { files: files2 } }); await sleep(0); requests[requests.length - 1].respond(200, {}, `["","ok"]`); const countAfterSuccess = requests.length; - successRef.current.retry(file1 as any); + ref2.current.retry(file2 as any); await sleep(0); - expect(requests.length).toBe(countAfterSuccess); - expect(successOnError.calledOnce).toBeTruthy(); - expect(successOnError.firstCall.args[0]?.name).toBe('UploadRetrySkipError'); - u1(); + expect(requests.length).toBe(countAfterSuccess + 1); + u2(); - // --- Path 2: customRequest sync throw clears cache, onError notified, retry fires UploadRetrySkipError --- let throwOnce = true; - const throwRef = React.createRef(); + const ref3 = React.createRef(); const throwOnError = sinon.spy(); - const { container: c2, unmount: u2 } = render( + let retryOnStartCalled = 0; + const { container: c3, unmount: u3 } = render( { + retryOnStartCalled += 1; + }} customRequest={() => { if (throwOnce) { throwOnce = false; @@ -463,35 +469,137 @@ describe('uploader', () => { />, ); - const file2 = { - name: 'throw-cleanup.png', + const file3 = { + name: 'throw-retry.png', toString() { return this.name; }, }; - (file2 as any).uid = 'uid-throw-path'; - const files2 = [file2]; - (files2 as any).item = (i: number) => files2[i]; + const files3 = [file3]; + (files3 as any).item = (i: number) => files3[i]; const initialRequestCount = requests.length; - fireEvent.change(c2.querySelector('input')!, { target: { files: files2 } }); + fireEvent.change(c3.querySelector('input')!, { target: { files: files3 } }); await sleep(0); - // customRequest sync throw: onError notified, no real XHR created expect(throwOnError.calledOnce).toBeTruthy(); expect(throwOnError.firstCall.args[0]?.message).toBe('sync throw'); expect(requests.length).toBe(initialRequestCount); - // Cache cleared after throw, retry fires UploadRetrySkipError - throwRef.current.retry(file2 as any); + retryOnStartCalled = 0; + ref3.current.retry(file3 as any); await sleep(0); - expect(requests.length).toBe(initialRequestCount); - expect(throwOnError.callCount).toBe(2); - expect(throwOnError.secondCall.args[0]?.name).toBe('UploadRetrySkipError'); + 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); + + const ajaxRef = (ref1 as any).current?.uploader; + const uid = Object.keys(ajaxRef.reqs)[0]; + delete ajaxRef.reqs[uid]; + + 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('retry does not send request after unmount', async () => { + const uploadRef = React.createRef(); + const { container, unmount } = render(); + + const file = { + name: 'unmount-test.png', + toString() { + return this.name; + }, + }; + 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; + + const ajaxRef = (uploadRef as any).current?.uploader; + const ajaxRetry = ajaxRef.retry; + + unmount(); + + ajaxRetry.call(ajaxRef, file as any); + await sleep(0); + + expect(requests.length).toBe(initialRequestCount); + }); + it('drag to upload', done => { const input = uploader.container.querySelector('input')!; From 9ff3ddd749c544e521466fc0cb3b10291b868707 Mon Sep 17 00:00:00 2001 From: lh01217311 Date: Wed, 30 Sep 2026 17:41:32 +0800 Subject: [PATCH 5/5] fix: normalize non-Error catch and complete retry docs --- README.md | 8 +++--- README.zh-CN.md | 8 +++--- src/AjaxUploader.tsx | 5 ++-- tests/uploader.spec.tsx | 63 +++++++++++++++++++++++++++++++++++++++-- 4 files changed, 70 insertions(+), 14 deletions(-) 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 18a0c33..7f3cf51 100644 --- a/src/AjaxUploader.tsx +++ b/src/AjaxUploader.tsx @@ -317,9 +317,8 @@ class AjaxUploader extends Component { } catch (e) { delete this.reqs[uid]; const { onError } = this.props; - const err = e as UploadRequestError; - err.name = err.name || 'UploadRequestError'; - onError?.(err, null, parsedFile); + const err = e instanceof Error ? e : new Error(String(e)); + onError?.(err as UploadRequestError, null, parsedFile); } } diff --git a/tests/uploader.spec.tsx b/tests/uploader.spec.tsx index 21a8b76..37c8d70 100644 --- a/tests/uploader.spec.tsx +++ b/tests/uploader.spec.tsx @@ -521,9 +521,7 @@ describe('uploader', () => { fireEvent.change(c1.querySelector('input')!, { target: { files: files1 } }); await sleep(0); - const ajaxRef = (ref1 as any).current?.uploader; - const uid = Object.keys(ajaxRef.reqs)[0]; - delete ajaxRef.reqs[uid]; + ref1.current.abort(file1 as any); onStartCallCount = 0; @@ -570,6 +568,65 @@ describe('uploader', () => { 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 does not send request after unmount', async () => { const uploadRef = React.createRef(); const { container, unmount } = render();