diff --git a/packages/forms/signals/src/directive/control_native.ts b/packages/forms/signals/src/directive/control_native.ts index 6c12e3ce76bf..fac33154d659 100644 --- a/packages/forms/signals/src/directive/control_native.ts +++ b/packages/forms/signals/src/directive/control_native.ts @@ -62,7 +62,16 @@ export function nativeControlCreate( }; // Pass undefined as the raw value since the parse function doesn't care about it. host.listenToDom('input', () => parser.setRawValue(undefined)); - host.listenToDom('blur', () => parent.state().markAsTouched()); + + // Use addEventListener directly so the blur handler does not unconditionally call + // markViewDirty. When the control is already touched, markAsTouched() is a no-op signal + // write and no CD should be scheduled; when it isn't, the signal write itself schedules CD. + // In a large application, every avoided change detection cycle is a win: fewer things + // Angular has to check, less time spent traversing the component tree. + const nativeEl = host.nativeElement; + const blurListener = () => parent.state().markAsTouched(); + nativeEl.addEventListener('blur', blurListener); + parent.destroyRef.onDestroy(() => nativeEl.removeEventListener('blur', blurListener)); // TODO: move extraction to first update pass? if (isInput(input) && inputRequiresValidityTracking(input)) { diff --git a/packages/forms/signals/test/web/form_field.spec.ts b/packages/forms/signals/test/web/form_field.spec.ts index 9e46a213c6a7..519960526864 100644 --- a/packages/forms/signals/test/web/form_field.spec.ts +++ b/packages/forms/signals/test/web/form_field.spec.ts @@ -29,6 +29,7 @@ import { viewChildren, ViewContainerRef, ViewEncapsulation, + ɵChangeDetectionScheduler, } from '@angular/core'; import {TestBed} from '@angular/core/testing'; @@ -5096,6 +5097,37 @@ describe('field directive', () => { expect(field().touched()).toBe(true); }); + it('should not notify the change detection scheduler on blur when control is already touched', () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + f = form(signal('')); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const inputEl = fixture.nativeElement.firstChild as HTMLInputElement; + const field = fixture.componentInstance.f; + + // Put the control into the already-touched state so the next blur is a no-op. + act(() => field().markAsTouched()); + expect(field().touched()).toBe(true); + + const scheduler = TestBed.inject(ɵChangeDetectionScheduler); + const notifySpy = spyOn(scheduler, 'notify'); + + // Blur when already touched — nothing changed, so CD must not be scheduled. + // + // Regression: when the blur listener was registered via host.listenToDom('blur', ...), + // wrapListener called markViewDirty unconditionally before running the callback, which + // called scheduler.notify(NotificationSource.Listener) even when selfTouched was + // already true and the signal write was a no-op. + inputEl.dispatchEvent(new Event('blur')); + + expect(notifySpy).not.toHaveBeenCalled(); + }); + it('should synchronize with custom control touched status', () => { @Component({ selector: 'my-input',