From ae1dc16bb0d30b6e87b0f98b7989e6685d856e31 Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Tue, 6 Jan 2026 20:56:05 -0500 Subject: [PATCH] fix(forms): clean up abort listener after timeout Removes the abort event listener once the debounce timeout completes. This avoids lingering listeners, prevents potential memory leaks, and ensures the abort logic runs at most once. (cherry picked from commit e7d99f02cba503aa7a30f71d388aef353205fff7) --- .../forms/signals/src/api/rules/debounce.ts | 14 ++++++++-- .../signals/test/node/api/debounce.spec.ts | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/forms/signals/src/api/rules/debounce.ts b/packages/forms/signals/src/api/rules/debounce.ts index 1503e6483d5..a050f323259 100644 --- a/packages/forms/signals/src/api/rules/debounce.ts +++ b/packages/forms/signals/src/api/rules/debounce.ts @@ -42,8 +42,18 @@ export function debounce( function debounceForDuration(durationInMilliseconds: number): Debouncer { return (_context, abortSignal) => { return new Promise((resolve) => { - const timeoutId = setTimeout(resolve, durationInMilliseconds); - abortSignal.addEventListener('abort', () => clearTimeout(timeoutId)); + let timeoutId: ReturnType | undefined; + + const onAbort = () => { + clearTimeout(timeoutId); + }; + + timeoutId = setTimeout(() => { + abortSignal.removeEventListener('abort', onAbort); + resolve(); + }, durationInMilliseconds); + + abortSignal.addEventListener('abort', onAbort, {once: true}); }); }; } diff --git a/packages/forms/signals/test/node/api/debounce.spec.ts b/packages/forms/signals/test/node/api/debounce.spec.ts index dbd43829348..f7c18d78132 100644 --- a/packages/forms/signals/test/node/api/debounce.spec.ts +++ b/packages/forms/signals/test/node/api/debounce.spec.ts @@ -237,6 +237,34 @@ describe('debounce', () => { expect(abortSpy).toHaveBeenCalledTimes(1); expect(street.value()).toBe('1600 Amphitheatre Pkwy'); }); + + it('should remove abort listener when debounce completes', async () => { + const addListenerSpy = spyOn(AbortSignal.prototype, 'addEventListener').and.callThrough(); + const removeListenerSpy = spyOn( + AbortSignal.prototype, + 'removeEventListener', + ).and.callThrough(); + + const address = signal({street: ''}); + const addressForm = form( + address, + (address) => { + debounce(address.street, 1); + }, + options(), + ); + const street = addressForm.street(); + + street.setControlValue('1600 Amphitheatre Pkwy'); + expect(addListenerSpy).toHaveBeenCalledOnceWith('abort', jasmine.any(Function), { + once: true, + }); + expect(removeListenerSpy).not.toHaveBeenCalled(); + + await timeout(10); + expect(street.value()).toBe('1600 Amphitheatre Pkwy'); + expect(removeListenerSpy).toHaveBeenCalledOnceWith('abort', jasmine.any(Function)); + }); }); });