From 640458daaf097dede696508a9fb223d41fbe1217 Mon Sep 17 00:00:00 2001 From: iruizsalinas Date: Sat, 25 Apr 2026 17:43:59 +0200 Subject: [PATCH] fix(xhr): unsubscribe cancelToken and signal on error, timeout, and abort paths (#10787) * test(xhr): add regression for cancelToken and signal listener cleanup on error paths * fix(xhr): unsubscribe cancelToken and signal on error, timeout, and abort paths --------- Co-authored-by: Jay --- lib/adapters/xhr.js | 4 +++ tests/browser/cancel.browser.test.js | 46 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/lib/adapters/xhr.js b/lib/adapters/xhr.js index 1d795505..fd43e91a 100644 --- a/lib/adapters/xhr.js +++ b/lib/adapters/xhr.js @@ -108,6 +108,7 @@ export default isXHRAdapterSupported && } reject(new AxiosError('Request aborted', AxiosError.ECONNABORTED, config, request)); + done(); // Clean up request request = null; @@ -123,6 +124,7 @@ export default isXHRAdapterSupported && // attach the underlying event for consumers who want details err.event = event || null; reject(err); + done(); request = null; }; @@ -143,6 +145,7 @@ export default isXHRAdapterSupported && request ) ); + done(); // Clean up request request = null; @@ -192,6 +195,7 @@ export default isXHRAdapterSupported && } reject(!cancel || cancel.type ? new CanceledError(null, config, request) : cancel); request.abort(); + done(); request = null; }; diff --git a/tests/browser/cancel.browser.test.js b/tests/browser/cancel.browser.test.js index 96cf658a..05e0bd52 100644 --- a/tests/browser/cancel.browser.test.js +++ b/tests/browser/cancel.browser.test.js @@ -170,4 +170,50 @@ describe('cancel (vitest browser)', () => { const error = await promise.catch((thrown) => thrown); expect(axios.isCancel(error)).toBe(true); }); + + describe('listener cleanup on error paths', () => { + for (const { label, trigger } of [ + { label: 'network error', trigger: (r) => r.onerror(new Error('Network Error')) }, + { label: 'timeout', trigger: (r) => r.ontimeout() }, + { label: 'browser abort', trigger: (r) => r.onabort() }, + ]) { + it(`unsubscribes cancelToken listener after ${label}`, async () => { + const source = axios.CancelToken.source(); + const promise = axios + .get('/foo/bar', { cancelToken: source.token }) + .catch((thrown) => thrown); + + const request = await waitForRequest(); + trigger(request); + await promise; + + expect(source.token._listeners || []).toEqual([]); + }); + } + + it('removes AbortSignal listener after network error', async () => { + const controller = new AbortController(); + let listenerCount = 0; + const nativeAdd = controller.signal.addEventListener.bind(controller.signal); + const nativeRemove = controller.signal.removeEventListener.bind(controller.signal); + controller.signal.addEventListener = (type, fn, options) => { + if (type === 'abort') listenerCount++; + return nativeAdd(type, fn, options); + }; + controller.signal.removeEventListener = (type, fn, options) => { + if (type === 'abort') listenerCount--; + return nativeRemove(type, fn, options); + }; + + const promise = axios + .get('/foo/bar', { signal: controller.signal }) + .catch((thrown) => thrown); + + const request = await waitForRequest(); + request.onerror(new Error('Network Error')); + await promise; + + expect(listenerCount).toBe(0); + }); + }); });