diff --git a/goldens/public-api/forms/forms.d.ts b/goldens/public-api/forms/forms.d.ts index 8c18a985be..251ce95472 100644 --- a/goldens/public-api/forms/forms.d.ts +++ b/goldens/public-api/forms/forms.d.ts @@ -237,7 +237,7 @@ export declare class FormControl extends AbstractControl { }): void; } -export declare class FormControlDirective extends NgControl implements OnChanges { +export declare class FormControlDirective extends NgControl implements OnChanges, OnDestroy { get control(): FormControl; form: FormControl; set isDisabled(isDisabled: boolean); @@ -247,6 +247,7 @@ export declare class FormControlDirective extends NgControl implements OnChanges viewModel: any; constructor(validators: (Validator | ValidatorFn)[], asyncValidators: (AsyncValidator | AsyncValidatorFn)[], valueAccessors: ControlValueAccessor[], _ngModelWarningConfig: string | null); ngOnChanges(changes: SimpleChanges): void; + ngOnDestroy(): void; viewToModelUpdate(newValue: any): void; } @@ -295,7 +296,7 @@ export declare class FormGroup extends AbstractControl { }): void; } -export declare class FormGroupDirective extends ControlContainer implements Form, OnChanges { +export declare class FormGroupDirective extends ControlContainer implements Form, OnChanges, OnDestroy { get control(): FormGroup; directives: FormControlName[]; form: FormGroup; @@ -311,6 +312,7 @@ export declare class FormGroupDirective extends ControlContainer implements Form getFormArray(dir: FormArrayName): FormArray; getFormGroup(dir: FormGroupName): FormGroup; ngOnChanges(changes: SimpleChanges): void; + ngOnDestroy(): void; onReset(): void; onSubmit($event: Event): boolean; removeControl(dir: FormControlName): void; diff --git a/packages/core/test/bundling/forms/bundle.golden_symbols.json b/packages/core/test/bundling/forms/bundle.golden_symbols.json index 822538400b..a4d53d02c5 100644 --- a/packages/core/test/bundling/forms/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms/bundle.golden_symbols.json @@ -770,6 +770,9 @@ { "name": "classIndexOf" }, + { + "name": "cleanUpControl" + }, { "name": "cleanUpValidators" }, diff --git a/packages/forms/src/directives/reactive_directives/form_control_directive.ts b/packages/forms/src/directives/reactive_directives/form_control_directive.ts index d64cd1e5f9..ba7bace32d 100644 --- a/packages/forms/src/directives/reactive_directives/form_control_directive.ts +++ b/packages/forms/src/directives/reactive_directives/form_control_directive.ts @@ -6,14 +6,14 @@ * found in the LICENSE file at https://angular.io/license */ -import {Directive, EventEmitter, forwardRef, Inject, InjectionToken, Input, OnChanges, Optional, Output, Self, SimpleChanges} from '@angular/core'; +import {Directive, EventEmitter, forwardRef, Inject, InjectionToken, Input, OnChanges, OnDestroy, Optional, Output, Self, SimpleChanges} from '@angular/core'; import {FormControl} from '../../model'; import {NG_ASYNC_VALIDATORS, NG_VALIDATORS} from '../../validators'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '../control_value_accessor'; import {NgControl} from '../ng_control'; import {ReactiveErrors} from '../reactive_errors'; -import {_ngModelWarning, isPropertyUpdated, selectValueAccessor, setUpControl} from '../shared'; +import {_ngModelWarning, cleanUpControl, isPropertyUpdated, selectValueAccessor, setUpControl} from '../shared'; import {AsyncValidator, AsyncValidatorFn, Validator, ValidatorFn} from '../validators'; @@ -51,7 +51,7 @@ export const formControlBinding: any = { * @publicApi */ @Directive({selector: '[formControl]', providers: [formControlBinding], exportAs: 'ngForm'}) -export class FormControlDirective extends NgControl implements OnChanges { +export class FormControlDirective extends NgControl implements OnChanges, OnDestroy { /** * Internal reference to the view model value. * @nodoc @@ -118,6 +118,10 @@ export class FormControlDirective extends NgControl implements OnChanges { /** @nodoc */ ngOnChanges(changes: SimpleChanges): void { if (this._isControlChanged(changes)) { + const previousForm = changes['form'].previousValue; + if (previousForm) { + cleanUpControl(previousForm, this, /* validateControlPresenceOnChange */ false); + } setUpControl(this.form, this); if (this.control.disabled && this.valueAccessor!.setDisabledState) { this.valueAccessor!.setDisabledState!(true); @@ -133,6 +137,13 @@ export class FormControlDirective extends NgControl implements OnChanges { } } + /** @nodoc */ + ngOnDestroy() { + if (this.form) { + cleanUpControl(this.form, this, /* validateControlPresenceOnChange */ false); + } + } + /** * @description * Returns an array that represents the path from the top-level form to this control. diff --git a/packages/forms/src/directives/reactive_directives/form_group_directive.ts b/packages/forms/src/directives/reactive_directives/form_group_directive.ts index b6a8f8f148..edf7cceaea 100644 --- a/packages/forms/src/directives/reactive_directives/form_group_directive.ts +++ b/packages/forms/src/directives/reactive_directives/form_group_directive.ts @@ -6,14 +6,14 @@ * found in the LICENSE file at https://angular.io/license */ -import {Directive, EventEmitter, forwardRef, Inject, Input, OnChanges, Optional, Output, Self, SimpleChanges} from '@angular/core'; +import {Directive, EventEmitter, forwardRef, Inject, Input, OnChanges, OnDestroy, Optional, Output, Self, SimpleChanges} from '@angular/core'; import {FormArray, FormControl, FormGroup} from '../../model'; import {NG_ASYNC_VALIDATORS, NG_VALIDATORS} from '../../validators'; import {ControlContainer} from '../control_container'; import {Form} from '../form_interface'; import {ReactiveErrors} from '../reactive_errors'; -import {cleanUpControl, cleanUpValidators, removeListItem, setUpControl, setUpFormContainer, setUpValidators, syncPendingControls} from '../shared'; +import {cleanUpControl, cleanUpFormContainer, cleanUpValidators, removeListItem, setUpControl, setUpFormContainer, setUpValidators, syncPendingControls} from '../shared'; import {AsyncValidator, AsyncValidatorFn, Validator, ValidatorFn} from '../validators'; import {FormControlName} from './form_control_name'; @@ -53,7 +53,7 @@ export const formDirectiveProvider: any = { host: {'(submit)': 'onSubmit($event)', '(reset)': 'onReset()'}, exportAs: 'ngForm' }) -export class FormGroupDirective extends ControlContainer implements Form, OnChanges { +export class FormGroupDirective extends ControlContainer implements Form, OnChanges, OnDestroy { /** * @description * Reports whether the form submission has been triggered. @@ -66,6 +66,12 @@ export class FormGroupDirective extends ControlContainer implements Form, OnChan */ private _oldForm: FormGroup|undefined; + /** + * Callback that should be invoked when controls in FormGroup or FormArray collection change + * (added or removed). This callback triggers corresponding DOM updates. + */ + private readonly _onCollectionChange = () => this._updateDomValue(); + /** * @description * Tracks the list of added `FormControlName` instances @@ -104,6 +110,23 @@ export class FormGroupDirective extends ControlContainer implements Form, OnChan } } + /** @nodoc */ + ngOnDestroy() { + if (this.form) { + cleanUpValidators(this.form, this, /* handleOnValidatorChange */ false); + + // Currently the `onCollectionChange` callback is rewritten each time the + // `_registerOnCollectionChange` function is invoked. The implication is that cleanup should + // happen *only* when the `onCollectionChange` callback was set by this directive instance. + // Otherwise it might cause overriding a callback of some other directive instances. We should + // consider updating this logic later to make it similar to how `onChange` callbacks are + // handled, see https://github.com/angular/angular/issues/39732 for additional info. + if (this.form._onCollectionChange === this._onCollectionChange) { + this.form._registerOnCollectionChange(() => {}); + } + } + } + /** * @description * Returns this directive's instance. @@ -161,6 +184,7 @@ export class FormGroupDirective extends ControlContainer implements Form, OnChan * @param dir The `FormControlName` directive instance. */ removeControl(dir: FormControlName): void { + cleanUpControl(dir.control || null, dir, /* validateControlPresenceOnChange */ false); removeListItem(this.directives, dir); } @@ -170,17 +194,18 @@ export class FormGroupDirective extends ControlContainer implements Form, OnChan * @param dir The `FormGroupName` directive instance. */ addFormGroup(dir: FormGroupName): void { - const ctrl: any = this.form.get(dir.path); - setUpFormContainer(ctrl, dir); - ctrl.updateValueAndValidity({emitEvent: false}); + this._setUpFormContainer(dir); } /** - * No-op method to remove the form group. + * Performs the necessary cleanup when a `FormGroupName` directive instance is removed from the + * view. * * @param dir The `FormGroupName` directive instance. */ - removeFormGroup(dir: FormGroupName): void {} + removeFormGroup(dir: FormGroupName): void { + this._cleanUpFormContainer(dir); + } /** * @description @@ -193,22 +218,23 @@ export class FormGroupDirective extends ControlContainer implements Form, OnChan } /** - * Adds a new `FormArrayName` directive instance to the form. + * Performs the necessary setup when a `FormArrayName` directive instance is added to the view. * * @param dir The `FormArrayName` directive instance. */ addFormArray(dir: FormArrayName): void { - const ctrl: any = this.form.get(dir.path); - setUpFormContainer(ctrl, dir); - ctrl.updateValueAndValidity({emitEvent: false}); + this._setUpFormContainer(dir); } /** - * No-op method to remove the form array. + * Performs the necessary cleanup when a `FormArrayName` directive instance is removed from the + * view. * * @param dir The `FormArrayName` directive instance. */ - removeFormArray(dir: FormArrayName): void {} + removeFormArray(dir: FormArrayName): void { + this._cleanUpFormContainer(dir); + } /** * @description @@ -281,8 +307,31 @@ export class FormGroupDirective extends ControlContainer implements Form, OnChan this.form._updateTreeValidity({emitEvent: false}); } + private _setUpFormContainer(dir: FormArrayName|FormGroupName): void { + const ctrl: any = this.form.get(dir.path); + setUpFormContainer(ctrl, dir); + // NOTE: this operation looks unnecessary in case no new validators were added in + // `setUpFormContainer` call. Consider updating this code to match the logic in + // `_cleanUpFormContainer` function. + ctrl.updateValueAndValidity({emitEvent: false}); + } + + private _cleanUpFormContainer(dir: FormArrayName|FormGroupName): void { + if (this.form) { + const ctrl: any = this.form.get(dir.path); + if (ctrl) { + const isControlUpdated = cleanUpFormContainer(ctrl, dir); + if (isControlUpdated) { + // Run validity check only in case a control was updated (i.e. view validators were + // removed) as removing view validators might cause validity to change. + ctrl.updateValueAndValidity({emitEvent: false}); + } + } + } + } + private _updateRegistrations() { - this.form._registerOnCollectionChange(() => this._updateDomValue()); + this.form._registerOnCollectionChange(this._onCollectionChange); if (this._oldForm) { this._oldForm._registerOnCollectionChange(() => {}); } diff --git a/packages/forms/src/directives/shared.ts b/packages/forms/src/directives/shared.ts index a66893ec0b..6d4d6441ff 100644 --- a/packages/forms/src/directives/shared.ts +++ b/packages/forms/src/directives/shared.ts @@ -30,6 +30,13 @@ export function controlPath(name: string|null, parent: ControlContainer): string return [...parent.path!, name!]; } +/** + * Links a Form control and a Form directive by setting up callbacks (such as `onChange`) on both + * instances. This function is typically invoked when form directive is being initialized. + * + * @param control Form control instance that should be linked. + * @param dir Directive that should be linked with a given control. + */ export function setUpControl(control: FormControl, dir: NgControl): void { if (typeof ngDevMode === 'undefined' || ngDevMode) { if (!control) _throwError(dir, 'Cannot find control with'); @@ -48,9 +55,22 @@ export function setUpControl(control: FormControl, dir: NgControl): void { setUpDisabledChangeHandler(control, dir); } -export function cleanUpControl(control: FormControl|null, dir: NgControl) { +/** + * Reverts configuration performed by the `setUpControl` control function. + * Effectively disconnects form control with a given form directive. + * This function is typically invoked when corresponding form directive is being destroyed. + * + * @param control Form control which should be cleaned up. + * @param dir Directive that should be disconnected from a given control. + * @param validateControlPresenceOnChange Flag that indicates whether onChange handler should + * contain asserts to verify that it's not called once directive is destroyed. We need this flag + * to avoid potentially breaking changes caused by better control cleanup introduced in #39235. + */ +export function cleanUpControl( + control: FormControl|null, dir: NgControl, + validateControlPresenceOnChange: boolean = true): void { const noop = () => { - if (typeof ngDevMode === 'undefined' || ngDevMode) { + if (validateControlPresenceOnChange && (typeof ngDevMode === 'undefined' || ngDevMode)) { _noControlError(dir); } }; @@ -146,16 +166,22 @@ export function setUpValidators( * @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). + * @returns true if a control was updated as a result of this action. */ export function cleanUpValidators( control: AbstractControl|null, dir: AbstractControlDirective, - handleOnValidatorChange: boolean): void { + handleOnValidatorChange: boolean): boolean { + let isControlUpdated = false; 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)); + const updatedValidators = validators.filter(validator => validator !== dir.validator); + if (updatedValidators.length !== validators.length) { + isControlUpdated = true; + control.setValidators(updatedValidators); + } } } @@ -163,8 +189,12 @@ export function cleanUpValidators( 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)); + const updatedAsyncValidators = + asyncValidators.filter(asyncValidator => asyncValidator !== dir.asyncValidator); + if (updatedAsyncValidators.length !== asyncValidators.length) { + isControlUpdated = true; + control.setAsyncValidators(updatedAsyncValidators); + } } } } @@ -175,6 +205,8 @@ export function cleanUpValidators( registerOnValidatorChange(dir._rawValidators, noop); registerOnValidatorChange(dir._rawAsyncValidators, noop); } + + return isControlUpdated; } function setUpViewChangePipeline(control: FormControl, dir: NgControl): void { @@ -220,6 +252,13 @@ function setUpModelChangePipeline(control: FormControl, dir: NgControl): void { }); } +/** + * Links a FormGroup or FormArray instance and corresponding Form directive by setting up validators + * present in the view. + * + * @param control FormGroup or FormArray instance that should be linked. + * @param dir Directive that provides view validators. + */ export function setUpFormContainer( control: FormGroup|FormArray, dir: AbstractFormGroupDirective|FormArrayName) { if (control == null && (typeof ngDevMode === 'undefined' || ngDevMode)) @@ -227,6 +266,18 @@ export function setUpFormContainer( setUpValidators(control, dir, /* handleOnValidatorChange */ false); } +/** + * Reverts the setup performed by the `setUpFormContainer` function. + * + * @param control FormGroup or FormArray instance that should be cleaned up. + * @param dir Directive that provided view validators. + * @returns true if a control was updated as a result of this action. + */ +export function cleanUpFormContainer( + control: FormGroup|FormArray, dir: AbstractFormGroupDirective|FormArrayName): boolean { + return cleanUpValidators(control, dir, /* handleOnValidatorChange */ false); +} + function _noControlError(dir: NgControl) { return _throwError(dir, 'There is no FormControl instance attached to form control element with'); } diff --git a/packages/forms/test/reactive_integration_spec.ts b/packages/forms/test/reactive_integration_spec.ts index 389042c4ba..72088ec4ee 100644 --- a/packages/forms/test/reactive_integration_spec.ts +++ b/packages/forms/test/reactive_integration_spec.ts @@ -10,7 +10,7 @@ import {ɵgetDOM as getDOM} from '@angular/common'; import {Component, Directive, forwardRef, Input, Type} from '@angular/core'; import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; 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 {AbstractControl, AsyncValidator, AsyncValidatorFn, COMPOSITION_BUFFER_MODE, ControlValueAccessor, DefaultValueAccessor, FormArray, FormControl, FormControlDirective, FormControlName, FormGroup, FormGroupDirective, FormsModule, NG_ASYNC_VALIDATORS, NG_VALIDATORS, NG_VALUE_ACCESSOR, ReactiveFormsModule, Validator, Validators} from '@angular/forms'; import {By} from '@angular/platform-browser/src/dom/debug/by'; import {dispatchEvent, sortedClassList} from '@angular/platform-browser/testing/src/browser_util'; import {merge, NEVER, of, timer} from 'rxjs'; @@ -18,6 +18,74 @@ import {map, tap} from 'rxjs/operators'; import {MyInput, MyInputForm} from './value_accessor_integration_spec'; +// Produces a new @Directive (with a given selector) that represents a validator class. +function createValidatorClass(selector: string) { + @Directive({ + selector, + providers: [{ + provide: NG_VALIDATORS, + useClass: forwardRef(() => CustomValidator), + multi: true, + }] + }) + class CustomValidator implements Validator { + validate(control: AbstractControl) { + return null; + } + } + return CustomValidator; +} + +// Produces a new @Directive (with a given selector) that represents an async validator class. +function createAsyncValidatorClass(selector: string) { + @Directive({ + selector, + providers: [{ + provide: NG_ASYNC_VALIDATORS, + useClass: forwardRef(() => CustomValidator), + multi: true, + }] + }) + class CustomValidator implements AsyncValidator { + validate(control: AbstractControl) { + return Promise.resolve(null); + } + } + return CustomValidator; +} + +// Produces a new @Directive (with a given selector) that represents a value accessor. +function createControlValueAccessor(selector: string) { + @Directive({ + selector, + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CustomValueAccessor), + multi: true, + }] + }) + class CustomValueAccessor implements ControlValueAccessor { + writeValue(value: any) {} + registerOnChange(fn: (value: any) => void) {} + registerOnTouched(fn: any) {} + } + return CustomValueAccessor; +} + +// Pre-create classes for validators. +const ViewValidatorA = createValidatorClass('[validators-a]'); +const ViewValidatorB = createValidatorClass('[validators-b]'); +const ViewValidatorC = createValidatorClass('[validators-c]'); + +// Pre-create classes for async validators. +const AsyncViewValidatorA = createAsyncValidatorClass('[validators-a]'); +const AsyncViewValidatorB = createAsyncValidatorClass('[validators-b]'); +const AsyncViewValidatorC = createAsyncValidatorClass('[validators-c]'); + +// Pre-create classes for value accessors. +const ValueAccessorA = createControlValueAccessor('[cva-a]'); +const ValueAccessorB = createControlValueAccessor('[cva-b]'); + { describe('reactive forms integration tests', () => { function initTest(component: Type, ...directives: Type[]): ComponentFixture { @@ -2471,6 +2539,10 @@ import {MyInput, MyInputForm} from './value_accessor_integration_spec'; }); describe('cleanup', () => { + // Symbol that indicates to the verification logic that a certain spy was not expected to be + // invoked. This symbol is used by the test helpers below. + const SHOULD_NOT_BE_CALLED = Symbol('SHOULD_NOT_BE_INVOKED'); + function expectValidatorsToBeCalled( syncValidatorSpy: jasmine.Spy, asyncValidatorSpy: jasmine.Spy, expected: {ctx: any, count: number}) { @@ -2482,9 +2554,101 @@ import {MyInput, MyInputForm} from './value_accessor_integration_spec'; }); } + function createValidatorSpy(): jasmine.Spy { + return jasmine.createSpy('asyncValidator').and.returnValue(null); + } + function createAsyncValidatorSpy(): jasmine.Spy { + return jasmine.createSpy('asyncValidator').and.returnValue(Promise.resolve(null)); + } + + // Sets up a control with validators and value accessors configured for a test. + function addOwnValidatorsAndAttachSpies(control: AbstractControl, fromView: any = {}): void { + const validatorSpy = createValidatorSpy(); + const asyncValidatorSpy = createAsyncValidatorSpy(); + const valueChangesSpy = jasmine.createSpy('controlValueChangesListener'); + const debug: any = { + validatorSpy, + asyncValidatorSpy, + valueChangesSpy, + }; + if (fromView.viewValidators) { + const [syncValidatorClass, asyncValidatorClass] = fromView.viewValidators; + debug.viewValidatorSpy = validatorSpyOn(syncValidatorClass); + debug.viewAsyncValidatorSpy = validatorSpyOn(asyncValidatorClass); + } + if (fromView.valueAccessor) { + debug.valueAccessorSpy = spyOn(fromView.valueAccessor.prototype, 'writeValue'); + } + (control as any).__debug__ = debug; + + control.valueChanges.subscribe(valueChangesSpy); + control.setValidators(validatorSpy); + control.setAsyncValidators(asyncValidatorSpy); + } + + // Resets all spies associated with given controls. + function resetSpies(...controls: AbstractControl[]): void { + controls.forEach((control: any) => { + const debug = control.__debug__; + debug.validatorSpy.calls.reset(); + debug.asyncValidatorSpy.calls.reset(); + debug.valueChangesSpy.calls.reset(); + if (debug.viewValidatorSpy) { + debug.viewValidatorSpy.calls.reset(); + } + if (debug.viewAsyncValidatorSpy) { + debug.viewAsyncValidatorSpy.calls.reset(); + } + if (debug.valueAccessorSpy) { + debug.valueAccessorSpy.calls.reset(); + } + }); + } + + // Verifies whether spy calls match expectations. + function verifySpyCalls(spy: any, expectedContext: any, expectedCallCount?: number) { + if (expectedContext === SHOULD_NOT_BE_CALLED) { + expect(spy).not.toHaveBeenCalled(); + } else { + expect(spy).toHaveBeenCalledWith(expectedContext); + if (expectedCallCount !== undefined) { + expect(spy.calls.count()).toBe(expectedCallCount); + } + } + } + + // Verify whether all spies attached to a given control match expectations. + function verifySpies(control: AbstractControl, expected: any = {}) { + const debug = (control as any).__debug__; + const viewValidatorCallCount = expected.viewValidatorCallCount ?? 1; + const ownValidatorCallCount = expected.ownValidatorCallCount ?? 1; + const valueAccessorCallCount = expected.valueAccessorCallCount ?? 1; + verifySpyCalls(debug.validatorSpy, expected.ownValidators, ownValidatorCallCount); + verifySpyCalls(debug.asyncValidatorSpy, expected.ownValidators, ownValidatorCallCount); + verifySpyCalls(debug.valueChangesSpy, expected.valueChanges); + if (debug.viewValidatorSpy) { + verifySpyCalls(debug.viewValidatorSpy, expected.viewValidators, viewValidatorCallCount); + } + if (debug.viewAsyncValidatorSpy) { + verifySpyCalls( + debug.viewAsyncValidatorSpy, expected.viewValidators, viewValidatorCallCount); + } + if (debug.valueAccessorSpy) { + verifySpyCalls(debug.valueAccessorSpy, expected.valueAccessor, valueAccessorCallCount); + } + } + + // Init a test with a predefined set of validator and value accessor classes. + function initCleanupTest(component: Type) { + const fixture = initTest( + component, ViewValidatorA, AsyncViewValidatorA, ViewValidatorB, AsyncViewValidatorB, + ViewValidatorC, AsyncViewValidatorC, ValueAccessorA, ValueAccessorB); + fixture.detectChanges(); + return fixture; + } + it('should clean up validators when FormGroup is replaced', () => { - const fixture = - initTest(FormGroupWithValidators, MyCustomValidator, MyCustomAsyncValidator); + const fixture = initTest(FormGroupWithValidators, ViewValidatorA, AsyncViewValidatorA); fixture.detectChanges(); const newForm = new FormGroup({login: new FormControl('NEW')}); @@ -2494,8 +2658,8 @@ import {MyInput, MyInputForm} from './value_accessor_integration_spec'; fixture.componentInstance.form = newForm; fixture.detectChanges(); - const validatorSpy = validatorSpyOn(MyCustomValidator); - const asyncValidatorSpy = validatorSpyOn(MyCustomAsyncValidator); + const validatorSpy = validatorSpyOn(ViewValidatorA); + const asyncValidatorSpy = validatorSpyOn(AsyncViewValidatorA); // Calling `setValue` for the OLD form should NOT trigger validator calls. oldForm.setValue({login: 'SOME-OLD-VALUE'}); @@ -2508,15 +2672,14 @@ import {MyInput, MyInputForm} from './value_accessor_integration_spec'; }); it('should clean up validators when FormControl inside FormGroup is replaced', () => { - const fixture = - initTest(FormControlWithValidators, MyCustomValidator, MyCustomAsyncValidator); + const fixture = initTest(FormControlWithValidators, ViewValidatorA, AsyncViewValidatorA); fixture.detectChanges(); const newControl = new FormControl('NEW')!; const oldControl = fixture.componentInstance.form.get('login')!; - const validatorSpy = validatorSpyOn(MyCustomValidator); - const asyncValidatorSpy = validatorSpyOn(MyCustomAsyncValidator); + const validatorSpy = validatorSpyOn(ViewValidatorA); + const asyncValidatorSpy = validatorSpyOn(AsyncViewValidatorA); // Update `login` form control with a new `FormControl` instance. fixture.componentInstance.form.removeControl('login'); @@ -2552,14 +2715,13 @@ import {MyInput, MyInputForm} from './value_accessor_integration_spec'; })); it('should call validators defined via `set[Async]Validators` after view init', () => { - const fixture = - initTest(FormControlWithValidators, MyCustomValidator, MyCustomAsyncValidator); + const fixture = initTest(FormControlWithValidators, ViewValidatorA, AsyncViewValidatorA); fixture.detectChanges(); const control = fixture.componentInstance.form.get('login')!; - const initialValidatorSpy = validatorSpyOn(MyCustomValidator); - const initialAsyncValidatorSpy = validatorSpyOn(MyCustomAsyncValidator); + const initialValidatorSpy = validatorSpyOn(ViewValidatorA); + const initialAsyncValidatorSpy = validatorSpyOn(AsyncViewValidatorA); initialValidatorSpy.calls.reset(); initialAsyncValidatorSpy.calls.reset(); @@ -2597,14 +2759,14 @@ import {MyInput, MyInputForm} from './value_accessor_integration_spec'; it('should cleanup validators on a control used for multiple `formControlName` directives', () => { const fixture = - initTest(NgForFormControlWithValidators, MyCustomValidator, MyCustomAsyncValidator); + initTest(NgForFormControlWithValidators, ViewValidatorA, AsyncViewValidatorA); fixture.detectChanges(); const newControl = new FormControl('b')!; const oldControl = fixture.componentInstance.form.get('login')!; - const validatorSpy = validatorSpyOn(MyCustomValidator); - const asyncValidatorSpy = validatorSpyOn(MyCustomAsyncValidator); + const validatorSpy = validatorSpyOn(ViewValidatorA); + const asyncValidatorSpy = validatorSpyOn(AsyncViewValidatorA); // Case 1: replace `login` form control with a new `FormControl` instance. fixture.componentInstance.form.removeControl('login'); @@ -2645,13 +2807,13 @@ import {MyInput, MyInputForm} from './value_accessor_integration_spec'; }); it('should cleanup directive-specific callbacks only', () => { - const fixture = initTest(MultipleFormControls, MyCustomValidator, MyCustomAsyncValidator); + const fixture = initTest(MultipleFormControls, ViewValidatorA, AsyncViewValidatorA); fixture.detectChanges(); const sharedControl = fixture.componentInstance.control; - const validatorSpy = validatorSpyOn(MyCustomValidator); - const asyncValidatorSpy = validatorSpyOn(MyCustomAsyncValidator); + const validatorSpy = validatorSpyOn(ViewValidatorA); + const asyncValidatorSpy = validatorSpyOn(AsyncViewValidatorA); sharedControl.setValue('b'); fixture.detectChanges(); @@ -2677,6 +2839,1202 @@ import {MyInput, MyInputForm} from './value_accessor_integration_spec'; expect(fixture.nativeElement.querySelector('#login').value).toBe('d'); expectValidatorsToBeCalled(validatorSpy, asyncValidatorSpy, {ctx: sharedControl, count: 1}); }); + + it('should clean up callbacks when FormControlDirective is destroyed (simple)', () => { + // Scenario: + // --------- + // [formControl] *ngIf + + const control = new FormControl(); + addOwnValidatorsAndAttachSpies(control, { + viewValidators: [ViewValidatorA, AsyncViewValidatorA], + valueAccessor: ValueAccessorA, + }); + + @Component({ + selector: 'app', + template: ` + + ` + }) + class App { + visible = true; + control = control; + } + + const fixture = initCleanupTest(App); + + resetSpies(control); + + // Case 1: update control value and verify all spies were called. + control.setValue('Initial value'); + fixture.detectChanges(); + + verifySpies(control, { + ownValidators: control, + viewValidators: control, + valueAccessor: 'Initial value', + valueChanges: 'Initial value', + }); + + // Case 2: hide form control and verify no directive-related callbacks + // (validators, value accessors) were invoked. + fixture.componentInstance.visible = false; + fixture.detectChanges(); + + // Reset all spies again, prepare for next check. + resetSpies(control); + + control.setValue('Updated Value'); + + // Expectation: + // - FormControlDirective was destroyed and connection to default value accessor and view + // validators should also be destroyed. + verifySpies(control, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: control, + valueAccessor: SHOULD_NOT_BE_CALLED, + valueChanges: 'Updated Value', + }); + + // Case 3: make the form control visible again and verify all callbacks are correctly + // attached. + fixture.componentInstance.visible = true; + fixture.detectChanges(); + + // Reset all spies again, prepare for next check. + resetSpies(control); + + control.setValue('Updated Value (v2)'); + + verifySpies(control, { + viewValidators: control, + ownValidators: control, + valueAccessor: 'Updated Value (v2)', + valueChanges: 'Updated Value (v2)', + }); + }); + + it('should clean up when FormControlDirective is destroyed (multiple instances)', () => { + // Scenario: + // --------- + // [formControl] *ngIf + // [formControl] + + const control = new FormControl(); + addOwnValidatorsAndAttachSpies(control, { + viewValidators: [ViewValidatorA, AsyncViewValidatorA], + valueAccessor: ValueAccessorA, + }); + + @Component({ + selector: 'app', + template: ` + + + ` + }) + class App { + visible = true; + control = control; + } + + const fixture = initCleanupTest(App); + + // Value accessor for the second without *ngIf. + const valueAccessorBSpy = spyOn(ValueAccessorB.prototype, 'writeValue'); + + // Reset all spies. + valueAccessorBSpy.calls.reset(); + resetSpies(control); + + // Case 1: update control value and verify all spies were called. + control.setValue('Initial value'); + fixture.detectChanges(); + + expect(valueAccessorBSpy).toHaveBeenCalledWith('Initial value'); + verifySpies(control, { + ownValidators: control, + viewValidators: control, + valueAccessor: 'Initial value', + valueChanges: 'Initial value', + }); + + // Case 2: hide form control and verify no directive-related callbacks + // (validators, value accessors) were invoked. + fixture.componentInstance.visible = false; + fixture.detectChanges(); + + // Reset all spies again, prepare for next check. + valueAccessorBSpy.calls.reset(); + resetSpies(control); + + control.setValue('Updated Value'); + + // Expectation: + // - FormControlDirective was destroyed and connection to a value accessor and view + // validators should also be destroyed. + // - Since there is a second instance of the FormControlDirective directive present in the + // template, we expect to see see calls to value accessor B (since it's applied to + // that directive instance) and validators applied on a control instance itself (not a + // part of a view setup). + expect(valueAccessorBSpy).toHaveBeenCalledWith('Updated Value'); + verifySpies(control, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: control, + valueAccessor: SHOULD_NOT_BE_CALLED, + valueChanges: 'Updated Value', + }); + }); + + it('should clean up callbacks when FormControlName directive is destroyed', () => { + // Scenario: + // --------- + // [formGroup] + // formControlName *ngIf + // formControlName + + const control = new FormControl(); + addOwnValidatorsAndAttachSpies(control, { + viewValidators: [ViewValidatorA, AsyncViewValidatorA], + valueAccessor: ValueAccessorA, + }); + + @Component({ + selector: 'app', + template: ` +
+ + +
+ ` + }) + class App { + visible = true; + group = new FormGroup({control}); + } + + const fixture = initCleanupTest(App); + + // DefaultValueAccessor will be used for the second where no custom CVA is defined. + const valueAccessorBSpy = spyOn(ValueAccessorB.prototype, 'writeValue'); + + // Reset all spies. + valueAccessorBSpy.calls.reset(); + resetSpies(control); + + // Case 1: update control value and verify all spies were called. + control.setValue('Initial value'); + fixture.detectChanges(); + + expect(valueAccessorBSpy).toHaveBeenCalledWith('Initial value'); + verifySpies(control, { + viewValidators: control, + ownValidators: control, + valueAccessor: 'Initial value', + valueChanges: 'Initial value', + }); + + // Case 2: hide form control and verify no directive-related callbacks + // (validators, value accessors) were invoked. + fixture.componentInstance.visible = false; + fixture.detectChanges(); + + // Reset all spies again, prepare for next check. + valueAccessorBSpy.calls.reset(); + resetSpies(control); + + control.setValue('Updated value'); + + // Expectation: + // - `FormControlName` was destroyed and connection to the value accessor A and + // validators should also be destroyed. + // - Since there is a second instance of `FormControlName` directive present in the + // template, we expect to see see calls to the value accessor B (since it's applied to + // that directive instance) and validators applied on a control instance itself (not a + // part of a view setup). + expect(valueAccessorBSpy).toHaveBeenCalledWith('Updated value'); + verifySpies(control, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: control, + valueAccessor: SHOULD_NOT_BE_CALLED, + valueChanges: 'Updated value', + }); + }); + + it('should clean up callbacks when FormGroupDirective is destroyed', () => { + // Scenario: + // --------- + // [formGroup] *ngIf + // [formControl] + + const control = new FormControl(); + addOwnValidatorsAndAttachSpies(control, { + viewValidators: [ViewValidatorA, AsyncViewValidatorA], + valueAccessor: ValueAccessorA, + }); + + const group = new FormGroup({control}); + addOwnValidatorsAndAttachSpies(group, { + viewValidators: [ViewValidatorB, AsyncViewValidatorB], + }); + + @Component({ + selector: 'app', + template: ` + +
+ +
+
+ ` + }) + class App { + visible = true; + control = control; + group = group; + } + + const fixture = initCleanupTest(App); + + resetSpies(group, control); + + // Case 1: update control value and verify that all spies were called. + control.setValue('Initial value'); + fixture.detectChanges(); + + verifySpies(control, { + viewValidators: control, + ownValidators: control, + valueAccessor: 'Initial value', + valueChanges: 'Initial value', + }); + verifySpies(group, { + viewValidators: group, + ownValidators: group, + valueChanges: {control: 'Initial value'}, + }); + + + // Case 2: hide form group and verify that no directive-related callbacks + // (validators, value accessors) are invoked when we set control value later. + fixture.componentInstance.visible = false; + fixture.detectChanges(); + + // Reset all spies again, prepare for next check. + resetSpies(group, control); + + control.setValue('Updated value'); + + // Expectation: + // - `FormGroupDirective` and `FormControlDirective` were destroyed, so connection to value + // accessor and view validators should also be destroyed. + // - Own validators directly attached to FormGroup and FormControl should still be invoked. + verifySpies(control, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: control, + valueAccessor: SHOULD_NOT_BE_CALLED, + valueChanges: 'Updated value', + }); + verifySpies(group, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: group, + valueChanges: {control: 'Updated value'}, + }); + + // Case 3: make the form control visible again and verify all callbacks are correctly + // attached and invoked. + fixture.componentInstance.visible = true; + fixture.detectChanges(); + + // Reset all spies again, prepare for next check. + resetSpies(group, control); + + control.setValue('Updated value (v2)'); + + verifySpies(control, { + viewValidators: control, + ownValidators: control, + valueAccessor: 'Updated value (v2)', + valueChanges: 'Updated value (v2)', + }); + verifySpies(group, { + viewValidators: group, + ownValidators: group, + valueChanges: {control: 'Updated value (v2)'}, + }); + }); + + it('should clean up when FormControl is destroyed (but parent FormGroup exists)', () => { + // Scenario: + // --------- + // [formGroup] + // [formControl] *ngIf + + const control = new FormControl(); + addOwnValidatorsAndAttachSpies(control, { + viewValidators: [ViewValidatorA, AsyncViewValidatorA], + valueAccessor: ValueAccessorA, + }); + + const group = new FormGroup({control}); + addOwnValidatorsAndAttachSpies(group, { + viewValidators: [ViewValidatorB, AsyncViewValidatorB], + }); + + @Component({ + selector: 'app', + template: ` +
+ +
+ ` + }) + class App { + visible = true; + control = control; + group = group; + } + + const fixture = initCleanupTest(App); + + resetSpies(group, control); + + // Case 1: update control value and verify that all spies were called. + control.setValue('Initial value'); + fixture.detectChanges(); + + verifySpies(control, { + viewValidators: control, + ownValidators: control, + valueAccessor: 'Initial value', + valueChanges: 'Initial value', + }); + verifySpies(group, { + viewValidators: group, + ownValidators: group, + valueChanges: {control: 'Initial value'}, + }); + + + // Case 2: hide form group and verify that no directive-related callbacks + // (validators, value accessors) are invoked when we set control value later. + fixture.componentInstance.visible = false; + fixture.detectChanges(); + + // Reset all spies again, prepare for next check. + resetSpies(group, control); + + group.setValue({control: 'Updated value'}); + + // Expectation: + // - `FormControlDirective` was destroyed, so connection to value accessor and view + // validators should also be destroyed. + // - Own validators directly attached to FormGroup and FormControl should still be invoked. + // - `FormGroupDirective` was *not* destroyed, so all view validators should be invoked. + verifySpies(control, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: control, + valueAccessor: SHOULD_NOT_BE_CALLED, + valueChanges: 'Updated value', + }); + verifySpies(group, { + viewValidators: group, + ownValidators: group, + valueChanges: {control: 'Updated value'}, + }); + }); + + it('should clean up controls produced by *ngFor', () => { + // Scenario: + // --------- + // [formGroup] + // [formControl] *ngFor + + const control = new FormControl(); + addOwnValidatorsAndAttachSpies(control, { + viewValidators: [ViewValidatorA, AsyncViewValidatorA], + valueAccessor: ValueAccessorA, + }); + + const group = new FormGroup({control}); + addOwnValidatorsAndAttachSpies(group, { + viewValidators: [ViewValidatorB, AsyncViewValidatorB], + }); + + @Component({ + selector: 'app', + template: ` +
+ + + +
+ ` + }) + class App { + visible = true; + control = control; + group = group; + logins = ['a', 'b', 'c']; + } + + const fixture = initCleanupTest(App); + + resetSpies(group, control); + + // Case 1: update control value and verify that all spies were called. + control.setValue('Initial value'); + fixture.detectChanges(); + + verifySpies(control, { + viewValidatorCallCount: 3, // since *ngFor produces 3 [formControl]s + valueAccessorCallCount: 3, // since *ngFor produces 3 [formControl]s + ownValidatorCallCount: 1, + viewValidators: control, + ownValidators: control, + valueAccessor: 'Initial value', + valueChanges: 'Initial value', + }); + verifySpies(group, { + viewValidators: group, + ownValidators: group, + valueChanges: {control: 'Initial value'}, + }); + + // Case 2: update the list of logins which would result in cleanups for no longer needed + // (thus destroyed) directives. + fixture.componentInstance.logins = ['c', 'd']; + fixture.detectChanges(); + + // Reset all spies again, prepare for next check. + resetSpies(group, control); + + control.setValue('Updated value'); + + verifySpies(control, { + viewValidatorCallCount: 2, // since now we have 2 items produced by *ngFor + valueAccessorCallCount: 2, // since now we have 2 items produced by *ngFor + ownValidatorCallCount: 1, + viewValidators: control, + ownValidators: control, + valueAccessor: 'Updated value', + valueChanges: 'Updated value', + }); + verifySpies(group, { + viewValidators: group, + ownValidators: group, + valueChanges: {control: 'Updated value'}, + }); + + // Case 3: hide form group and verify that no directive-related callbacks + // (validators, value accessors) are invoked when we set control value later. + fixture.componentInstance.visible = false; + fixture.detectChanges(); + + // Reset all spies again, prepare for next check. + resetSpies(group, control); + + control.setValue('Updated value (v2)'); + + verifySpies(control, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: control, + valueAccessor: SHOULD_NOT_BE_CALLED, + valueChanges: 'Updated value (v2)', + }); + verifySpies(group, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: group, + valueChanges: {control: 'Updated value (v2)'}, + }); + }); + + it('should clean up when FormArrayName is destroyed (but parent FormGroup exists)', () => { + // Scenario: + // --------- + // [formGroup] + // formArrayName + // formControlName *ngIf + + const control = new FormControl(); + addOwnValidatorsAndAttachSpies(control, { + viewValidators: [ViewValidatorA, AsyncViewValidatorA], + valueAccessor: ValueAccessorA, + }); + + const arr = new FormArray([control]); + addOwnValidatorsAndAttachSpies(arr, { + viewValidators: [ViewValidatorB, AsyncViewValidatorB], + }); + + const group = new FormGroup({arr}); + addOwnValidatorsAndAttachSpies(group, { + viewValidators: [ViewValidatorC, AsyncViewValidatorC], + }); + + @Component({ + selector: 'app', + template: ` +
+ + + +
+ ` + }) + class App { + visible = true; + group = group; + } + + const fixture = initCleanupTest(App); + + resetSpies(group, arr, control); + + // Case 1: update control value and verify that all spies were called. + control.setValue('Initial value'); + fixture.detectChanges(); + + verifySpies(control, { + viewValidators: control, + ownValidators: control, + valueAccessor: 'Initial value', + valueChanges: 'Initial value', + }); + verifySpies(arr, { + viewValidators: arr, + ownValidators: arr, + valueChanges: ['Initial value'], + }); + verifySpies(group, { + viewValidators: group, + ownValidators: group, + valueChanges: {arr: ['Initial value']}, + }); + + + // Case 2: hide form group and verify that no directive-related callbacks + // (validators, value accessors) are invoked when we set control value later. + fixture.componentInstance.visible = false; + fixture.detectChanges(); + + // Reset all spies again, prepare for next check. + resetSpies(group, arr, control); + + control.setValue('Updated value'); + + // Expectation: + // - `FormControlDirective` was destroyed, so connection to value accessor and view + // validators should also be destroyed. + // - Own validators directly attached to FormGroup, FormArray and FormControl should still + // be invoked. + // - `FormArrayName` was *not* destroyed, so all view validators should be invoked. + verifySpies(control, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: control, + valueAccessor: SHOULD_NOT_BE_CALLED, + valueChanges: 'Updated value', + }); + verifySpies(arr, { + viewValidators: arr, + ownValidators: arr, + valueChanges: ['Updated value'], + }); + verifySpies(group, { + viewValidators: group, + ownValidators: group, + valueChanges: {arr: ['Updated value']}, + }); + }); + + it('should clean up when FormArrayName is destroyed (but parent FormGroup exists)', () => { + // Scenario: + // --------- + // [formGroup] + // formArrayName *ngIf + // formControlName + + const control = new FormControl(); + addOwnValidatorsAndAttachSpies(control, { + viewValidators: [ViewValidatorA, AsyncViewValidatorA], + valueAccessor: ValueAccessorA, + }); + + const arr = new FormArray([control]); + addOwnValidatorsAndAttachSpies(arr, { + viewValidators: [ViewValidatorB, AsyncViewValidatorB], + }); + + const group = new FormGroup({arr}); + addOwnValidatorsAndAttachSpies(group, { + viewValidators: [ViewValidatorC, AsyncViewValidatorC], + }); + + @Component({ + selector: 'app', + template: ` +
+ + + +
+ ` + }) + class App { + visible = true; + group = group; + } + + const fixture = initCleanupTest(App); + + resetSpies(group, arr, control); + + // Case 1: update control value and verify that all spies were called. + control.setValue('Initial value'); + fixture.detectChanges(); + + verifySpies(control, { + viewValidators: control, + ownValidators: control, + valueAccessor: 'Initial value', + valueChanges: 'Initial value', + }); + verifySpies(arr, { + viewValidators: arr, + ownValidators: arr, + valueChanges: ['Initial value'], + }); + verifySpies(group, { + viewValidators: group, + ownValidators: group, + valueChanges: {arr: ['Initial value']}, + }); + + + // Case 2: hide form group and verify that no directive-related callbacks + // (validators, value accessors) are invoked when we set control value later. + fixture.componentInstance.visible = false; + fixture.detectChanges(); + + // Reset all spies again, prepare for next check. + resetSpies(group, arr, control); + + control.setValue('Updated value'); + + // Expectation: + // - `FormArrayName` was destroyed, so connection to view validators should be destroyed. + // - Own validators directly attached to FormGroup, FormArray and FormControl should still + // be invoked. + verifySpies(control, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: control, + valueAccessor: SHOULD_NOT_BE_CALLED, + valueChanges: 'Updated value', + }); + verifySpies(arr, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: arr, + valueChanges: ['Updated value'], + }); + verifySpies(group, { + viewValidators: group, + ownValidators: group, + valueChanges: {arr: ['Updated value']}, + }); + + // Case 3: make the form array control available again and verify all callbacks are + // correctly attached and invoked. + fixture.componentInstance.visible = true; + fixture.detectChanges(); + + // Reset all spies again, prepare for next check. + resetSpies(group, arr, control); + + control.setValue('Updated value (v2)'); + + verifySpies(control, { + viewValidators: control, + ownValidators: control, + valueAccessor: 'Updated value (v2)', + valueChanges: 'Updated value (v2)', + }); + verifySpies(arr, { + viewValidators: arr, + ownValidators: arr, + valueChanges: ['Updated value (v2)'], + }); + verifySpies(group, { + viewValidators: group, + ownValidators: group, + valueChanges: {arr: ['Updated value (v2)']}, + }); + }); + + it('should clean up all child controls when FormGroup is destroyed', () => { + // Scenario: + // --------- + // [formGroup] *ngIf + // formArrayName + // formControlName + + const control = new FormControl(); + addOwnValidatorsAndAttachSpies(control, { + viewValidators: [ViewValidatorA, AsyncViewValidatorA], + valueAccessor: ValueAccessorA, + }); + + const arr = new FormArray([control]); + addOwnValidatorsAndAttachSpies(arr, { + viewValidators: [ViewValidatorB, AsyncViewValidatorB], + }); + + const group = new FormGroup({arr}); + addOwnValidatorsAndAttachSpies(group, { + viewValidators: [ViewValidatorC, AsyncViewValidatorC], + }); + + @Component({ + selector: 'app', + template: ` +
+ + + +
+ ` + }) + class App { + visible = true; + group = group; + } + + const fixture = initCleanupTest(App); + + resetSpies(group, arr, control); + + // Case 1: update control value and verify that all spies were called. + control.setValue('Initial value'); + fixture.detectChanges(); + + verifySpies(control, { + viewValidators: control, + ownValidators: control, + valueAccessor: 'Initial value', + valueChanges: 'Initial value', + }); + verifySpies(arr, { + viewValidators: arr, + ownValidators: arr, + valueChanges: ['Initial value'], + }); + verifySpies(group, { + viewValidators: group, + ownValidators: group, + valueChanges: {arr: ['Initial value']}, + }); + + + // Case 2: hide form group and verify that no directive-related callbacks + // (validators, value accessors) are invoked when we set control value later. + fixture.componentInstance.visible = false; + fixture.detectChanges(); + + // Reset all spies again, prepare for next check. + resetSpies(group, arr, control); + + control.setValue('Updated value'); + + // Expectation: + // - `FormArrayName` was destroyed, so connection to view validators should be destroyed. + // - Own validators directly attached to FormGroup, FormArray and FormControl should still + // be invoked. + verifySpies(control, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: control, + valueAccessor: SHOULD_NOT_BE_CALLED, + valueChanges: 'Updated value', + }); + verifySpies(arr, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: arr, + valueChanges: ['Updated value'], + }); + verifySpies(group, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: group, + valueChanges: {arr: ['Updated value']}, + }); + + // Case 3: make the form group available again and verify all callbacks are correctly + // attached and invoked. + fixture.componentInstance.visible = true; + fixture.detectChanges(); + + // Reset all spies again, prepare for next check. + resetSpies(group, arr, control); + + control.setValue('Updated value (v2)'); + + verifySpies(control, { + viewValidators: control, + ownValidators: control, + valueAccessor: 'Updated value (v2)', + valueChanges: 'Updated value (v2)', + }); + verifySpies(arr, { + viewValidators: arr, + ownValidators: arr, + valueChanges: ['Updated value (v2)'], + }); + verifySpies(group, { + viewValidators: group, + ownValidators: group, + valueChanges: {arr: ['Updated value (v2)']}, + }); + }); + + it('should clean up all child controls (with *ngFor) when FormArrayName is destroyed', () => { + // Scenario: + // --------- + // [formGroup] + // formArrayName *ngIf + // formControlName *ngFor + + const controlA = new FormControl('A'); + addOwnValidatorsAndAttachSpies(controlA, { + viewValidators: [ViewValidatorA, AsyncViewValidatorA], + valueAccessor: ValueAccessorA, + }); + + const controlB = new FormControl('B'); + // Note: since ControlA and ControlB share the same set of validators and value accessor, we + // add spies just ones while configuring ControlA (it's not possible to add spies multiple + // times). + addOwnValidatorsAndAttachSpies(controlB, {}); + + const arr = new FormArray([controlA, controlB]); + addOwnValidatorsAndAttachSpies(arr, { + viewValidators: [ViewValidatorB, AsyncViewValidatorB], + }); + + const group = new FormGroup({arr}); + addOwnValidatorsAndAttachSpies(group, { + viewValidators: [ViewValidatorC, AsyncViewValidatorC], + }); + + @Component({ + selector: 'app', + template: ` +
+ + + + + +
+ ` + }) + class App { + visible = true; + group = group; + ids = [0, 1]; + } + + const fixture = initCleanupTest(App); + + resetSpies(group, arr, controlA, controlB); + + // Case 1: update control value and verify that all spies were called. + controlA.setValue('Updated A'); + fixture.detectChanges(); + + verifySpies(controlA, { + viewValidators: controlA, + ownValidators: controlA, + valueAccessor: 'Updated A', + valueChanges: 'Updated A', + }); + verifySpies(controlB, { + // ControlB is a sibling to ControlA, so updating ControlA has no effect on ControlB. + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: SHOULD_NOT_BE_CALLED, + valueAccessor: SHOULD_NOT_BE_CALLED, + valueChanges: SHOULD_NOT_BE_CALLED, + }); + verifySpies(arr, { + viewValidators: arr, + ownValidators: arr, + valueChanges: ['Updated A', 'B'], + }); + verifySpies(group, { + viewValidators: group, + ownValidators: group, + valueChanges: {arr: ['Updated A', 'B']}, + }); + + // Case 2: remove ControlA from the view by updating the list of ids. + // Verify that ControlA is detached from the view, but ControlB still works. + fixture.componentInstance.ids = [1]; + fixture.detectChanges(); + + // Reset all spies again, prepare for next check. + resetSpies(group, arr, controlA, controlB); + + controlA.setValue('Updated A (v2)'); + + verifySpies(controlA, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: controlA, + valueAccessor: SHOULD_NOT_BE_CALLED, + valueChanges: 'Updated A (v2)', + }); + verifySpies(controlB, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: SHOULD_NOT_BE_CALLED, + valueAccessor: SHOULD_NOT_BE_CALLED, + valueChanges: SHOULD_NOT_BE_CALLED, + }); + verifySpies(arr, { + viewValidators: arr, + ownValidators: arr, + valueChanges: ['Updated A (v2)', 'B'], + }); + verifySpies(group, { + viewValidators: group, + ownValidators: group, + valueChanges: {arr: ['Updated A (v2)', 'B']}, + }); + + // Case 3: hide form group and verify that no directive-related callbacks + // (validators, value accessors) are invoked when we set control value later. + fixture.componentInstance.visible = false; + fixture.detectChanges(); + + // Reset all spies again, prepare for next check. + resetSpies(group, arr, controlA, controlB); + + controlB.setValue('Updated B'); + + // Expectation: + // - `FormArrayName` was destroyed, so connection to view validators should be destroyed. + // - Own validators directly attached to FormGroup, FormArray and FormControl should still + // be invoked. + verifySpies(controlA, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: SHOULD_NOT_BE_CALLED, + valueAccessor: SHOULD_NOT_BE_CALLED, + valueChanges: SHOULD_NOT_BE_CALLED, + }); + verifySpies(controlB, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: controlB, + valueAccessor: SHOULD_NOT_BE_CALLED, + valueChanges: 'Updated B', + }); + verifySpies(arr, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: arr, + valueChanges: ['Updated A (v2)', 'Updated B'], + }); + verifySpies(group, { + viewValidators: group, + ownValidators: group, + valueChanges: {arr: ['Updated A (v2)', 'Updated B']}, + }); + }); + + it('should clean up all child controls when FormGroupName is destroyed', () => { + // Scenario: + // --------- + // [formGroup] + // formGroupName *ngIf + // formControlName + + const control = new FormControl(); + addOwnValidatorsAndAttachSpies(control, { + viewValidators: [ViewValidatorA, AsyncViewValidatorA], + valueAccessor: ValueAccessorA, + }); + + const group = new FormGroup({control}); + addOwnValidatorsAndAttachSpies(group, { + viewValidators: [ViewValidatorB, AsyncViewValidatorB], + }); + + const root = new FormGroup({group}); + addOwnValidatorsAndAttachSpies(root, { + viewValidators: [ViewValidatorC, AsyncViewValidatorC], + }); + + @Component({ + selector: 'app', + template: ` +
+ + + +
+ ` + }) + class App { + visible = true; + root = root; + } + + const fixture = initCleanupTest(App); + + resetSpies(root, group, control); + + // Case 1: update control value and verify that all spies were called. + control.setValue('Initial value'); + fixture.detectChanges(); + + verifySpies(control, { + viewValidators: control, + ownValidators: control, + valueAccessor: 'Initial value', + valueChanges: 'Initial value', + }); + verifySpies(group, { + viewValidators: group, + ownValidators: group, + valueChanges: {control: 'Initial value'}, + }); + verifySpies(root, { + viewValidators: root, + ownValidators: root, + valueChanges: {group: {control: 'Initial value'}}, + }); + + + // Case 2: hide form group and verify that no directive-related callbacks + // (validators, value accessors) are invoked when we set control value later. + fixture.componentInstance.visible = false; + fixture.detectChanges(); + + // Reset all spies again, prepare for next check. + resetSpies(root, group, control); + + control.setValue('Updated value'); + + // Expectation: + // - `FormGroupName` was destroyed, so connection to view validators should be destroyed. + // - Own validators directly attached to FormGroups and FormControl should still + // be invoked. + verifySpies(control, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: control, + valueAccessor: SHOULD_NOT_BE_CALLED, + valueChanges: 'Updated value', + }); + verifySpies(group, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: group, + valueChanges: {control: 'Updated value'}, + }); + verifySpies(root, { + viewValidators: root, + ownValidators: root, + valueChanges: {group: {control: 'Updated value'}}, + }); + }); + + it('should clean up all child controls when FormGroup is destroyed', () => { + // Scenario: + // --------- + // [formGroup] *ngIf + // formGroupName + // formControlName + + const control = new FormControl(); + addOwnValidatorsAndAttachSpies(control, { + viewValidators: [ViewValidatorA, AsyncViewValidatorA], + valueAccessor: ValueAccessorA, + }); + + const group = new FormGroup({control}); + addOwnValidatorsAndAttachSpies(group, { + viewValidators: [ViewValidatorB, AsyncViewValidatorB], + }); + + const root = new FormGroup({group}); + addOwnValidatorsAndAttachSpies(root, { + viewValidators: [ViewValidatorC, AsyncViewValidatorC], + }); + + @Component({ + selector: 'app', + template: ` +
+ + + +
+ ` + }) + class App { + visible = true; + root = root; + } + + const fixture = initCleanupTest(App); + + resetSpies(root, group, control); + + // Case 1: update control value and verify that all spies were called. + control.setValue('Initial value'); + fixture.detectChanges(); + + verifySpies(control, { + viewValidators: control, + ownValidators: control, + valueAccessor: 'Initial value', + valueChanges: 'Initial value', + }); + verifySpies(group, { + viewValidators: group, + ownValidators: group, + valueChanges: {control: 'Initial value'}, + }); + verifySpies(root, { + viewValidators: root, + ownValidators: root, + valueChanges: {group: {control: 'Initial value'}}, + }); + + + // Case 2: hide form group and verify that no directive-related callbacks + // (validators, value accessors) are invoked when we set control value later. + fixture.componentInstance.visible = false; + fixture.detectChanges(); + + // Reset all spies again, prepare for next check. + resetSpies(root, group, control); + + control.setValue('Updated value'); + + // Expectation: + // - `FormGroup` was destroyed, so connection to view validators should be destroyed. + // - Own validators directly attached to FormGroups and FormControl should still + // be invoked. + verifySpies(control, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: control, + valueAccessor: SHOULD_NOT_BE_CALLED, + valueChanges: 'Updated value', + }); + verifySpies(group, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: group, + valueChanges: {control: 'Updated value'}, + }); + verifySpies(root, { + viewValidators: SHOULD_NOT_BE_CALLED, + ownValidators: root, + valueChanges: {group: {control: 'Updated value'}}, + }); + }); }); }); } @@ -2914,7 +4272,7 @@ class UniqLoginWrapper { @Component({ selector: 'form-group-with-validators', template: ` -
+
` @@ -2946,7 +4304,7 @@ class FormControlWithAsyncValidatorFn { selector: 'form-control-with-validators', template: `
- +
` }) @@ -2958,21 +4316,10 @@ class FormControlWithValidators { selector: 'ngfor-form-controls-with-validators', template: `
- +
- +
` }) @@ -2987,7 +4334,7 @@ class MultipleFormControls { template: `
- +
` @@ -2995,32 +4342,4 @@ class MultipleFormControls { 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); - } } \ No newline at end of file