From a3849611b7e65dd5745e1d182df6d526fddb302c Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Fri, 12 Jun 2020 16:22:22 -0700 Subject: [PATCH] fix(forms): clean up connection between FormControl/FormGroup and corresponding directive instances (#39235) Prior to this commit, removing `FormControlDirective` and `FormGroupName` directive instances didn't clear the callbacks previously registered on FromControl/FormGroup class instances. As a result, these callbacks were executed even after `FormControlDirective` and `FormGroupName` directive instances were destroyed. That was also causing memory leaks since these callbacks also retained references to DOM elements. This commit updates the cleanup logic to take care of properly detaching FormControl/FormGroup/FormArray instances from the view by removing view-specific callback at destroy time. Closes #20007, #37431, #39590. PR Close #39235 --- goldens/public-api/forms/forms.d.ts | 6 +- .../bundling/forms/bundle.golden_symbols.json | 3 + .../form_control_directive.ts | 17 +- .../form_group_directive.ts | 79 +- packages/forms/src/directives/shared.ts | 63 +- .../forms/test/reactive_integration_spec.ts | 1445 ++++++++++++++++- 6 files changed, 1524 insertions(+), 89 deletions(-) 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