fix(forms): remove validators while cleaning up a control (#39234)

Prior to this commit, the `cleanUpControl` function (responsible for cleaning up control instance)
was not taking validators into account. As a result, these validators remain registered on a detached
form control instance, thus causing memory leaks. This commit updates the `cleanUpControl` function
logic to also run validators cleanup.

As a part of this change, the logic to setup and cleanup validators was refactored and moved to
separate functions (with completely opposite behavior), so that they can be reused in the future.

This commit doesn't add the `cleanUpControl` calls to all possible places, it just fixes the cases
where this function is being called, but doesn't fully perform a cleanup. The `cleanUpControl`
function calls will be added to other parts of code (to avoid more memory leaks) in a followup PR.

PR Close #39234
This commit is contained in:
Andrew Kushnir 2020-07-22 19:09:24 -07:00 committed by Joey Perrott
parent 535ebb18a3
commit e96b379385
5 changed files with 445 additions and 43 deletions

View File

@ -755,6 +755,9 @@
{ {
"name": "classIndexOf" "name": "classIndexOf"
}, },
{
"name": "cleanUpValidators"
},
{ {
"name": "cleanUpView" "name": "cleanUpView"
}, },
@ -962,6 +965,12 @@
{ {
"name": "getConstant" "name": "getConstant"
}, },
{
"name": "getControlAsyncValidators"
},
{
"name": "getControlValidators"
},
{ {
"name": "getCurrentTNode" "name": "getCurrentTNode"
}, },
@ -1316,6 +1325,9 @@
{ {
"name": "mergeHostAttrs" "name": "mergeHostAttrs"
}, },
{
"name": "mergeValidators"
},
{ {
"name": "modelGroupProvider" "name": "modelGroupProvider"
}, },
@ -1421,6 +1433,9 @@
{ {
"name": "registerHostBindingOpCodes" "name": "registerHostBindingOpCodes"
}, },
{
"name": "registerOnValidatorChange"
},
{ {
"name": "registerPostOrderHooks" "name": "registerPostOrderHooks"
}, },
@ -1547,6 +1562,9 @@
{ {
"name": "setUpFormContainer" "name": "setUpFormContainer"
}, },
{
"name": "setUpValidators"
},
{ {
"name": "shareSubjectFactory" "name": "shareSubjectFactory"
}, },

View File

@ -9,11 +9,11 @@
import {Directive, EventEmitter, forwardRef, Inject, Input, OnChanges, Optional, Output, Self, SimpleChanges} from '@angular/core'; import {Directive, EventEmitter, forwardRef, Inject, Input, OnChanges, Optional, Output, Self, SimpleChanges} from '@angular/core';
import {FormArray, FormControl, FormGroup} from '../../model'; import {FormArray, FormControl, FormGroup} from '../../model';
import {NG_ASYNC_VALIDATORS, NG_VALIDATORS, Validators} from '../../validators'; import {NG_ASYNC_VALIDATORS, NG_VALIDATORS} from '../../validators';
import {ControlContainer} from '../control_container'; import {ControlContainer} from '../control_container';
import {Form} from '../form_interface'; import {Form} from '../form_interface';
import {ReactiveErrors} from '../reactive_errors'; import {ReactiveErrors} from '../reactive_errors';
import {cleanUpControl, removeDir, setUpControl, setUpFormContainer, syncPendingControls} from '../shared'; import {cleanUpControl, cleanUpValidators, removeDir, setUpControl, setUpFormContainer, setUpValidators, syncPendingControls} from '../shared';
import {AsyncValidator, AsyncValidatorFn, Validator, ValidatorFn} from '../validators'; import {AsyncValidator, AsyncValidatorFn, Validator, ValidatorFn} from '../validators';
import {FormControlName} from './form_control_name'; import {FormControlName} from './form_control_name';
@ -60,8 +60,11 @@ export class FormGroupDirective extends ControlContainer implements Form, OnChan
*/ */
public readonly submitted: boolean = false; public readonly submitted: boolean = false;
// TODO(issue/24571): remove '!'. /**
private _oldForm!: FormGroup; * Reference to an old form group input value, which is needed to cleanup old instance in case it
* was replaced with a new one.
*/
private _oldForm: FormGroup|undefined;
/** /**
* @description * @description
@ -97,6 +100,7 @@ export class FormGroupDirective extends ControlContainer implements Form, OnChan
this._updateValidators(); this._updateValidators();
this._updateDomValue(); this._updateDomValue();
this._updateRegistrations(); this._updateRegistrations();
this._oldForm = this.form;
} }
} }
@ -266,7 +270,9 @@ export class FormGroupDirective extends ControlContainer implements Form, OnChan
this.directives.forEach(dir => { this.directives.forEach(dir => {
const newCtrl: any = this.form.get(dir.path); const newCtrl: any = this.form.get(dir.path);
if (dir.control !== newCtrl) { if (dir.control !== newCtrl) {
cleanUpControl(dir.control, dir); // Note: the value of the `dir.control` may not be defined, for example when it's a first
// `FormControl` that is added to a `FormGroup` instance (via `addControl` call).
cleanUpControl(dir.control || null, dir);
if (newCtrl) setUpControl(newCtrl, dir); if (newCtrl) setUpControl(newCtrl, dir);
(dir as {control: FormControl}).control = newCtrl; (dir as {control: FormControl}).control = newCtrl;
} }
@ -277,14 +283,16 @@ export class FormGroupDirective extends ControlContainer implements Form, OnChan
private _updateRegistrations() { private _updateRegistrations() {
this.form._registerOnCollectionChange(() => this._updateDomValue()); this.form._registerOnCollectionChange(() => this._updateDomValue());
if (this._oldForm) this._oldForm._registerOnCollectionChange(() => {}); if (this._oldForm) {
this._oldForm = this.form; this._oldForm._registerOnCollectionChange(() => {});
}
} }
private _updateValidators() { private _updateValidators() {
this.form.validator = Validators.compose([this.form.validator, this.validator]); setUpValidators(this.form, this, /* handleOnValidatorChange */ false);
this.form.asyncValidator = if (this._oldForm) {
Validators.composeAsync([this.form.asyncValidator, this.asyncValidator]); cleanUpValidators(this._oldForm, this, /* handleOnValidatorChange */ false);
}
} }
private _checkFormPresent() { private _checkFormPresent() {

View File

@ -8,8 +8,8 @@
import {isDevMode} from '@angular/core'; import {isDevMode} from '@angular/core';
import {FormArray, FormControl, FormGroup} from '../model'; import {AbstractControl, FormArray, FormControl, FormGroup} from '../model';
import {Validators} from '../validators'; import {getControlAsyncValidators, getControlValidators, mergeValidators} from '../validators';
import {AbstractControlDirective} from './abstract_control_directive'; import {AbstractControlDirective} from './abstract_control_directive';
import {AbstractFormGroupDirective} from './abstract_form_group_directive'; import {AbstractFormGroupDirective} from './abstract_form_group_directive';
@ -25,7 +25,7 @@ import {FormArrayName} from './reactive_directives/form_group_name';
import {ReactiveErrors} from './reactive_errors'; import {ReactiveErrors} from './reactive_errors';
import {SelectControlValueAccessor} from './select_control_value_accessor'; import {SelectControlValueAccessor} from './select_control_value_accessor';
import {SelectMultipleControlValueAccessor} from './select_multiple_control_value_accessor'; import {SelectMultipleControlValueAccessor} from './select_multiple_control_value_accessor';
import {AsyncValidator, AsyncValidatorFn, Validator, ValidatorFn} from './validators'; import {AsyncValidatorFn, Validator, ValidatorFn} from './validators';
export function controlPath(name: string|null, parent: ControlContainer): string[] { export function controlPath(name: string|null, parent: ControlContainer): string[] {
@ -38,8 +38,8 @@ export function setUpControl(control: FormControl, dir: NgControl): void {
if (!dir.valueAccessor) _throwError(dir, 'No value accessor for form control with'); if (!dir.valueAccessor) _throwError(dir, 'No value accessor for form control with');
} }
control.validator = Validators.compose([control.validator!, dir.validator]); setUpValidators(control, dir, /* handleOnValidatorChange */ true);
control.asyncValidator = Validators.composeAsync([control.asyncValidator!, dir.asyncValidator]);
dir.valueAccessor!.writeValue(control.value); dir.valueAccessor!.writeValue(control.value);
setUpViewChangePipeline(control, dir); setUpViewChangePipeline(control, dir);
@ -52,20 +52,9 @@ export function setUpControl(control: FormControl, dir: NgControl): void {
dir.valueAccessor!.setDisabledState!(isDisabled); dir.valueAccessor!.setDisabledState!(isDisabled);
}); });
} }
// re-run validation when validator binding changes, e.g. minlength=3 -> minlength=4
dir._rawValidators.forEach((validator: Validator|ValidatorFn) => {
if ((<Validator>validator).registerOnValidatorChange)
(<Validator>validator).registerOnValidatorChange!(() => control.updateValueAndValidity());
});
dir._rawAsyncValidators.forEach((validator: AsyncValidator|AsyncValidatorFn) => {
if ((<Validator>validator).registerOnValidatorChange)
(<Validator>validator).registerOnValidatorChange!(() => control.updateValueAndValidity());
});
} }
export function cleanUpControl(control: FormControl, dir: NgControl) { export function cleanUpControl(control: FormControl|null, dir: NgControl) {
const noop = () => { const noop = () => {
if (typeof ngDevMode === 'undefined' || ngDevMode) { if (typeof ngDevMode === 'undefined' || ngDevMode) {
_noControlError(dir); _noControlError(dir);
@ -75,21 +64,100 @@ export function cleanUpControl(control: FormControl, dir: NgControl) {
dir.valueAccessor!.registerOnChange(noop); dir.valueAccessor!.registerOnChange(noop);
dir.valueAccessor!.registerOnTouched(noop); dir.valueAccessor!.registerOnTouched(noop);
dir._rawValidators.forEach((validator: any) => { cleanUpValidators(control, dir, /* handleOnValidatorChange */ true);
if (validator.registerOnValidatorChange) {
validator.registerOnValidatorChange(null);
}
});
dir._rawAsyncValidators.forEach((validator: any) => {
if (validator.registerOnValidatorChange) {
validator.registerOnValidatorChange(null);
}
});
if (control) control._clearChangeFns(); if (control) control._clearChangeFns();
} }
function registerOnValidatorChange<V>(validators: (V|Validator)[], onChange: () => void): void {
validators.forEach((validator: (V|Validator)) => {
if ((<Validator>validator).registerOnValidatorChange)
(<Validator>validator).registerOnValidatorChange!(onChange);
});
}
/**
* Sets up sync and async directive validators on provided form control.
* This function merges validators from the directive into the validators of the control.
*
* @param control Form control where directive validators should be setup.
* @param dir Directive instance that contains validators to be setup.
* @param handleOnValidatorChange Flag that determines whether directive validators should be setup
* to handle validator input change.
*/
export function setUpValidators(
control: AbstractControl, dir: AbstractControlDirective,
handleOnValidatorChange: boolean): void {
const validators = getControlValidators(control);
if (dir.validator !== null) {
control.setValidators(mergeValidators<ValidatorFn>(validators, dir.validator));
} else if (typeof validators === 'function') {
// If sync validators are represented by a single validator function, we force the
// `Validators.compose` call to happen by executing the `setValidators` function with
// an array that contains that function. We need this to avoid possible discrepancies in
// validators behavior, so sync validators are always processed by the `Validators.compose`.
// Note: we should consider moving this logic inside the `setValidators` function itself, so we
// have consistent behavior on AbstractControl API level. The same applies to the async
// validators logic below.
control.setValidators([validators]);
}
const asyncValidators = getControlAsyncValidators(control);
if (dir.asyncValidator !== null) {
control.setAsyncValidators(
mergeValidators<AsyncValidatorFn>(asyncValidators, dir.asyncValidator));
} else if (typeof asyncValidators === 'function') {
control.setAsyncValidators([asyncValidators]);
}
// Re-run validation when validator binding changes, e.g. minlength=3 -> minlength=4
if (handleOnValidatorChange) {
const onValidatorChange = () => control.updateValueAndValidity();
registerOnValidatorChange<ValidatorFn>(dir._rawValidators, onValidatorChange);
registerOnValidatorChange<AsyncValidatorFn>(dir._rawAsyncValidators, onValidatorChange);
}
}
/**
* Cleans up sync and async directive validators on provided form control.
* This function reverts the setup performed by the `setUpValidators` function, i.e.
* removes directive-specific validators from a given control instance.
*
* @param control Form control from where directive validators should be removed.
* @param dir Directive instance that contains validators to be removed.
* @param handleOnValidatorChange Flag that determines whether directive validators should also be
* cleaned up to stop handling validator input change (if previously configured to do so).
*/
export function cleanUpValidators(
control: AbstractControl|null, dir: AbstractControlDirective,
handleOnValidatorChange: boolean): void {
if (control !== null) {
if (dir.validator !== null) {
const validators = getControlValidators(control);
if (Array.isArray(validators) && validators.length > 0) {
// Filter out directive validator function.
control.setValidators(validators.filter(validator => validator !== dir.validator));
}
}
if (dir.asyncValidator !== null) {
const asyncValidators = getControlAsyncValidators(control);
if (Array.isArray(asyncValidators) && asyncValidators.length > 0) {
// Filter out directive async validator function.
control.setAsyncValidators(
asyncValidators.filter(asyncValidator => asyncValidator !== dir.asyncValidator));
}
}
}
if (handleOnValidatorChange) {
// Clear onValidatorChange callbacks by providing a noop function.
const noop = () => {};
registerOnValidatorChange<ValidatorFn>(dir._rawValidators, noop);
registerOnValidatorChange<AsyncValidatorFn>(dir._rawAsyncValidators, noop);
}
}
function setUpViewChangePipeline(control: FormControl, dir: NgControl): void { function setUpViewChangePipeline(control: FormControl, dir: NgControl): void {
dir.valueAccessor!.registerOnChange((newValue: any) => { dir.valueAccessor!.registerOnChange((newValue: any) => {
control._pendingValue = newValue; control._pendingValue = newValue;
@ -130,8 +198,7 @@ export function setUpFormContainer(
control: FormGroup|FormArray, dir: AbstractFormGroupDirective|FormArrayName) { control: FormGroup|FormArray, dir: AbstractFormGroupDirective|FormArrayName) {
if (control == null && (typeof ngDevMode === 'undefined' || ngDevMode)) if (control == null && (typeof ngDevMode === 'undefined' || ngDevMode))
_throwError(dir, 'Cannot find control with'); _throwError(dir, 'Cannot find control with');
control.validator = Validators.compose([control.validator, dir.validator]); setUpValidators(control, dir, /* handleOnValidatorChange */ false);
control.asyncValidator = Validators.composeAsync([control.asyncValidator, dir.asyncValidator]);
} }
function _noControlError(dir: NgControl) { function _noControlError(dir: NgControl) {

View File

@ -551,4 +551,29 @@ export function composeAsyncValidators(validators: Array<AsyncValidator|AsyncVal
return validators != null ? return validators != null ?
Validators.composeAsync(normalizeValidators<AsyncValidatorFn>(validators)) : Validators.composeAsync(normalizeValidators<AsyncValidatorFn>(validators)) :
null; null;
}
/**
* Merges raw control validators with a given directive validator and returns the combined list of
* validators as an array.
*/
export function mergeValidators<V>(controlValidators: V|V[]|null, dirValidator: V): V[] {
if (controlValidators === null) return [dirValidator];
return Array.isArray(controlValidators) ? [...controlValidators, dirValidator] :
[controlValidators, dirValidator];
}
/**
* Retrieves the list of raw synchronous validators attached to a given control.
*/
export function getControlValidators(control: AbstractControl): ValidatorFn|ValidatorFn[]|null {
return (control as any)._rawValidators as ValidatorFn | ValidatorFn[] | null;
}
/**
* Retrieves the list of raw asynchronous validators attached to a given control.
*/
export function getControlAsyncValidators(control: AbstractControl): AsyncValidatorFn|
AsyncValidatorFn[]|null {
return (control as any)._rawAsyncValidators as AsyncValidatorFn | AsyncValidatorFn[] | null;
} }

View File

@ -9,11 +9,12 @@
import {ɵgetDOM as getDOM} from '@angular/common'; import {ɵgetDOM as getDOM} from '@angular/common';
import {Component, Directive, forwardRef, Input, Type} from '@angular/core'; import {Component, Directive, forwardRef, Input, Type} from '@angular/core';
import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
import {AbstractControl, AsyncValidator, AsyncValidatorFn, COMPOSITION_BUFFER_MODE, FormArray, FormControl, FormControlDirective, FormControlName, FormGroup, FormGroupDirective, FormsModule, NG_ASYNC_VALIDATORS, NG_VALIDATORS, ReactiveFormsModule, Validators} from '@angular/forms'; import {expect} from '@angular/core/testing/src/testing_internal';
import {AbstractControl, AsyncValidator, AsyncValidatorFn, COMPOSITION_BUFFER_MODE, FormArray, FormControl, FormControlDirective, FormControlName, FormGroup, FormGroupDirective, FormsModule, NG_ASYNC_VALIDATORS, NG_VALIDATORS, ReactiveFormsModule, Validator, Validators} from '@angular/forms';
import {By} from '@angular/platform-browser/src/dom/debug/by'; import {By} from '@angular/platform-browser/src/dom/debug/by';
import {dispatchEvent, sortedClassList} from '@angular/platform-browser/testing/src/browser_util'; import {dispatchEvent, sortedClassList} from '@angular/platform-browser/testing/src/browser_util';
import {merge, timer} from 'rxjs'; import {merge, NEVER, of, timer} from 'rxjs';
import {tap} from 'rxjs/operators'; import {map, tap} from 'rxjs/operators';
import {MyInput, MyInputForm} from './value_accessor_integration_spec'; import {MyInput, MyInputForm} from './value_accessor_integration_spec';
@ -25,6 +26,11 @@ import {MyInput, MyInputForm} from './value_accessor_integration_spec';
return TestBed.createComponent(component); return TestBed.createComponent(component);
} }
// Helper method that attaches a spy to a `validate` function on a Validator class.
function validatorSpyOn(validatorClass: any) {
return spyOn(validatorClass.prototype, 'validate').and.callThrough();
}
describe('basic functionality', () => { describe('basic functionality', () => {
it('should work with single controls', () => { it('should work with single controls', () => {
const fixture = initTest(FormControlComp); const fixture = initTest(FormControlComp);
@ -1785,6 +1791,23 @@ import {MyInput, MyInputForm} from './value_accessor_integration_spec';
expect(control.hasError('required')).toEqual(false); expect(control.hasError('required')).toEqual(false);
}); });
// Note: this scenario goes against validator function rules were `null` is the only
// representation of a successful check. However the `Validators.combine` has a side-effect
// where falsy values are treated as success and `null` is returned from the wrapper function.
// The goal of this test is to prevent regressions for validators that return falsy values by
// mistake and rely on the `Validators.compose` side-effects to normalize the value to `null`
// instead.
it('should treat validators that return `undefined` as successful', () => {
const fixture = initTest(FormControlComp);
const validatorFn = (control: AbstractControl) => control.value ?? undefined;
const control = new FormControl(undefined, validatorFn);
fixture.componentInstance.control = control;
fixture.detectChanges();
expect(control.status).toBe('VALID');
expect(control.errors).toBe(null);
});
it('should use sync validators defined in html', () => { it('should use sync validators defined in html', () => {
const fixture = initTest(LoginIsEmptyWrapper, LoginIsEmptyValidator); const fixture = initTest(LoginIsEmptyWrapper, LoginIsEmptyValidator);
const form = new FormGroup({ const form = new FormGroup({
@ -2446,6 +2469,181 @@ import {MyInput, MyInputForm} from './value_accessor_integration_spec';
expect(fixture.componentInstance.control.value).toEqual('updatedValue'); expect(fixture.componentInstance.control.value).toEqual('updatedValue');
}); });
}); });
describe('cleanup', () => {
function expectValidatorsToBeCalled(
syncValidatorSpy: jasmine.Spy, asyncValidatorSpy: jasmine.Spy,
expected: {ctx: any, count: number}) {
[syncValidatorSpy, asyncValidatorSpy].forEach((spy: jasmine.Spy<jasmine.Func>) => {
spy.calls.all().forEach((call: jasmine.CallInfo<jasmine.Func>) => {
expect(call.args[0]).toBe(expected.ctx);
});
expect(spy).toHaveBeenCalledTimes(expected.count);
});
}
it('should clean up validators when FormGroup is replaced', () => {
const fixture =
initTest(FormGroupWithValidators, MyCustomValidator, MyCustomAsyncValidator);
fixture.detectChanges();
const newForm = new FormGroup({login: new FormControl('NEW')});
const oldForm = fixture.componentInstance.form;
// Update `form` input with a new value.
fixture.componentInstance.form = newForm;
fixture.detectChanges();
const validatorSpy = validatorSpyOn(MyCustomValidator);
const asyncValidatorSpy = validatorSpyOn(MyCustomAsyncValidator);
// Calling `setValue` for the OLD form should NOT trigger validator calls.
oldForm.setValue({login: 'SOME-OLD-VALUE'});
expect(validatorSpy).not.toHaveBeenCalled();
expect(asyncValidatorSpy).not.toHaveBeenCalled();
// Calling `setValue` for the NEW (active) form should trigger validator calls.
newForm.setValue({login: 'SOME-NEW-VALUE'});
expectValidatorsToBeCalled(validatorSpy, asyncValidatorSpy, {ctx: newForm, count: 1});
});
it('should clean up validators when FormControl inside FormGroup is replaced', () => {
const fixture =
initTest(FormControlWithValidators, MyCustomValidator, MyCustomAsyncValidator);
fixture.detectChanges();
const newControl = new FormControl('NEW')!;
const oldControl = fixture.componentInstance.form.get('login')!;
const validatorSpy = validatorSpyOn(MyCustomValidator);
const asyncValidatorSpy = validatorSpyOn(MyCustomAsyncValidator);
// Update `login` form control with a new `FormControl` instance.
fixture.componentInstance.form.removeControl('login');
fixture.componentInstance.form.addControl('login', newControl);
fixture.detectChanges();
validatorSpy.calls.reset();
asyncValidatorSpy.calls.reset();
// Calling `setValue` for the OLD control should NOT trigger validator calls.
oldControl.setValue('SOME-OLD-VALUE');
expect(validatorSpy).not.toHaveBeenCalled();
expect(asyncValidatorSpy).not.toHaveBeenCalled();
// Calling `setValue` for the NEW (active) control should trigger validator calls.
newControl.setValue('SOME-NEW-VALUE');
expectValidatorsToBeCalled(validatorSpy, asyncValidatorSpy, {ctx: newControl, count: 1});
});
it('should keep control in pending state if async validator never emits', fakeAsync(() => {
const fixture = initTest(FormControlWithAsyncValidatorFn);
fixture.detectChanges();
const control = fixture.componentInstance.form.get('login')!;
expect(control.status).toBe('PENDING');
control.setValue('SOME-NEW-VALUE');
tick();
// Since validator never emits, we expect a control to be retained in a pending state.
expect(control.status).toBe('PENDING');
expect(control.errors).toBe(null);
}));
it('should call validators defined via `set[Async]Validators` after view init', () => {
const fixture =
initTest(FormControlWithValidators, MyCustomValidator, MyCustomAsyncValidator);
fixture.detectChanges();
const control = fixture.componentInstance.form.get('login')!;
const initialValidatorSpy = validatorSpyOn(MyCustomValidator);
const initialAsyncValidatorSpy = validatorSpyOn(MyCustomAsyncValidator);
initialValidatorSpy.calls.reset();
initialAsyncValidatorSpy.calls.reset();
control.setValue('VALUE-A');
// Expect initial validators (setup during view creation) to be called.
expectValidatorsToBeCalled(
initialValidatorSpy, initialAsyncValidatorSpy, {ctx: control, count: 1});
initialValidatorSpy.calls.reset();
initialAsyncValidatorSpy.calls.reset();
// Create new validators and corresponding spies.
const newValidatorSpy = jasmine.createSpy('newValidator').and.returnValue(null);
const newAsyncValidatorSpy =
jasmine.createSpy('newAsyncValidator').and.returnValue(of(null));
// Set new validators to a control that is already used in a view.
// Expect that new validators are applied and old validators are removed.
control.setValidators(newValidatorSpy);
control.setAsyncValidators(newAsyncValidatorSpy);
// Update control value to trigger validation.
control.setValue('VALUE-B');
// Verify that initial (inactive) validators were not called.
expect(initialValidatorSpy).not.toHaveBeenCalled();
expect(initialAsyncValidatorSpy).not.toHaveBeenCalled();
// Verify that newly applied validators were executed.
expectValidatorsToBeCalled(newValidatorSpy, newAsyncValidatorSpy, {ctx: control, count: 1});
});
it('should cleanup validators on a control used for multiple `formControlName` directive',
() => {
const fixture =
initTest(NgForFormControlWithValidators, MyCustomValidator, MyCustomAsyncValidator);
fixture.detectChanges();
const newControl = new FormControl('b')!;
const oldControl = fixture.componentInstance.form.get('login')!;
const validatorSpy = validatorSpyOn(MyCustomValidator);
const asyncValidatorSpy = validatorSpyOn(MyCustomAsyncValidator);
// Case 1: replace `login` form control with a new `FormControl` instance.
fixture.componentInstance.form.removeControl('login');
fixture.componentInstance.form.addControl('login', newControl);
fixture.detectChanges();
// Check that validators were called with a new control as a context
// and each validator function was called for each control (so 3 times each).
expectValidatorsToBeCalled(validatorSpy, asyncValidatorSpy, {ctx: newControl, count: 3});
validatorSpy.calls.reset();
asyncValidatorSpy.calls.reset();
// Calling `setValue` for the OLD control should NOT trigger validator calls.
oldControl.setValue('SOME-OLD-VALUE');
expect(validatorSpy).not.toHaveBeenCalled();
expect(asyncValidatorSpy).not.toHaveBeenCalled();
// Calling `setValue` for the NEW (active) control should trigger validator calls.
newControl.setValue('SOME-NEW-VALUE');
// Check that setting a value on a new control triggers validator calls.
expectValidatorsToBeCalled(validatorSpy, asyncValidatorSpy, {ctx: newControl, count: 3});
// Case 2: update `logins` to render a new list of elements.
fixture.componentInstance.logins = ['a', 'b', 'c', 'd', 'e', 'f'];
fixture.detectChanges();
validatorSpy.calls.reset();
asyncValidatorSpy.calls.reset();
// Calling `setValue` for the NEW (active) control should trigger validator calls.
newControl.setValue('SOME-NEW-VALUE-2');
// Check that setting a value on a new control triggers validator calls for updated set
// of controls (one for each element in the `logins` array).
expectValidatorsToBeCalled(validatorSpy, asyncValidatorSpy, {ctx: newControl, count: 6});
});
});
}); });
} }
@ -2678,3 +2876,89 @@ class UniqLoginWrapper {
// TODO(issue/24571): remove '!'. // TODO(issue/24571): remove '!'.
form!: FormGroup; form!: FormGroup;
} }
@Component({
selector: 'form-group-with-validators',
template: `
<div [formGroup]="form" my-custom-validator my-custom-async-validator>
<input type="text" formControlName="login">
</div>
`
})
class FormGroupWithValidators {
form = new FormGroup({login: new FormControl('INITIAL')});
}
@Component({
selector: 'form-control-with-validators',
template: `
<div [formGroup]="form">
<input type="text" formControlName="login">
</div>
`
})
class FormControlWithAsyncValidatorFn {
control = new FormControl('INITIAL');
form = new FormGroup({login: this.control});
constructor() {
this.control.setAsyncValidators(() => {
return NEVER.pipe(map((_: any) => ({timeoutError: true})));
});
}
}
@Component({
selector: 'form-control-with-validators',
template: `
<div [formGroup]="form">
<input type="text" formControlName="login" my-custom-validator my-custom-async-validator>
</div>
`
})
class FormControlWithValidators {
form = new FormGroup({login: new FormControl('INITIAL')});
}
@Component({
selector: 'ngfor-form-controls-with-validators',
template: `
<div [formGroup]="form">
<ng-container *ngFor="let login of logins">
<input type="radio" formControlName="login" [value]="login" my-custom-validator my-custom-async-validator>
</ng-container>
</div>
`
})
class NgForFormControlWithValidators {
form = new FormGroup({login: new FormControl('a')});
logins = ['a', 'b', 'c'];
}
@Directive({
selector: '[my-custom-validator]',
providers: [{
provide: NG_VALIDATORS,
useClass: forwardRef(() => MyCustomValidator),
multi: true,
}]
})
class MyCustomValidator implements Validator {
validate(control: AbstractControl) {
return null;
}
}
@Directive({
selector: '[my-custom-async-validator]',
providers: [{
provide: NG_ASYNC_VALIDATORS,
useClass: forwardRef(() => MyCustomAsyncValidator),
multi: true,
}]
})
class MyCustomAsyncValidator implements AsyncValidator {
validate(control: AbstractControl) {
return Promise.resolve(null);
}
}