From fe3b98dedd8ac20e0e73e3bad1fb81a14337379a Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Tue, 25 Jun 2024 15:12:23 -0700 Subject: [PATCH] test: Convert some material tests to zoneless --- src/google-maps/map-event-manager.spec.ts | 2 +- .../autocomplete/autocomplete.spec.ts | 15 --- .../autocomplete/autocomplete.zone.spec.ts | 27 ++++- src/material/dialog/dialog.spec.ts | 19 ++-- src/material/form-field/form-field.ts | 14 ++- src/material/select/select.spec.ts | 101 +++++++++++++----- tools/public_api_guard/material/form-field.md | 11 +- tools/tslint-rules/noZoneDependenciesRule.ts | 32 +++++- tslint.json | 6 +- 9 files changed, 148 insertions(+), 79 deletions(-) diff --git a/src/google-maps/map-event-manager.spec.ts b/src/google-maps/map-event-manager.spec.ts index c7b294da7dfa..2b46951ff180 100644 --- a/src/google-maps/map-event-manager.spec.ts +++ b/src/google-maps/map-event-manager.spec.ts @@ -1,4 +1,4 @@ -import {NgZone} from '@angular/core'; +import {type NgZone} from '@angular/core'; import {MapEventManager} from './map-event-manager'; describe('MapEventManager', () => { diff --git a/src/material/autocomplete/autocomplete.spec.ts b/src/material/autocomplete/autocomplete.spec.ts index cee2f191382b..04ba3638a894 100644 --- a/src/material/autocomplete/autocomplete.spec.ts +++ b/src/material/autocomplete/autocomplete.spec.ts @@ -160,21 +160,6 @@ describe('MDC-based MatAutocomplete', () => { .toContain('California'); }); - it('should show the panel when the first open is after the initial zone stabilization', waitForAsync(() => { - // Note that we're running outside the Angular zone, in order to be able - // to test properly without the subscription from `_subscribeToClosingActions` - // giving us a false positive. - fixture.ngZone!.runOutsideAngular(() => { - fixture.componentInstance.trigger.openPanel(); - - Promise.resolve().then(() => { - expect(fixture.componentInstance.panel.showPanel) - .withContext(`Expected panel to be visible.`) - .toBe(true); - }); - }); - })); - it('should close the panel when the user clicks away', waitForAsync(async () => { dispatchFakeEvent(input, 'focusin'); fixture.detectChanges(); diff --git a/src/material/autocomplete/autocomplete.zone.spec.ts b/src/material/autocomplete/autocomplete.zone.spec.ts index 0f799d7999b2..2daf7166eea5 100644 --- a/src/material/autocomplete/autocomplete.zone.spec.ts +++ b/src/material/autocomplete/autocomplete.zone.spec.ts @@ -11,7 +11,7 @@ import { ViewChildren, provideZoneChangeDetection, } from '@angular/core'; -import {TestBed, waitForAsync} from '@angular/core/testing'; +import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {Subscription} from 'rxjs'; @@ -43,6 +43,31 @@ describe('MDC-based MatAutocomplete Zone.js integration', () => { return TestBed.createComponent(component); } + + describe('panel toggling', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = createComponent(SimpleAutocomplete); + fixture.detectChanges(); + }); + + it('should show the panel when the first open is after the initial zone stabilization', waitForAsync(() => { + // Note that we're running outside the Angular zone, in order to be able + // to test properly without the subscription from `_subscribeToClosingActions` + // giving us a false positive. + fixture.ngZone!.runOutsideAngular(() => { + fixture.componentInstance.trigger.openPanel(); + + Promise.resolve().then(() => { + expect(fixture.componentInstance.panel.showPanel) + .withContext(`Expected panel to be visible.`) + .toBe(true); + }); + }); + })); + }); + it('should emit from `autocomplete.closed` after click outside inside the NgZone', waitForAsync(async () => { const inZoneSpy = jasmine.createSpy('in zone spy'); diff --git a/src/material/dialog/dialog.spec.ts b/src/material/dialog/dialog.spec.ts index 214e190d51fd..0a472e363fa5 100644 --- a/src/material/dialog/dialog.spec.ts +++ b/src/material/dialog/dialog.spec.ts @@ -23,7 +23,6 @@ import { Injectable, Injector, NgModule, - NgZone, TemplateRef, ViewChild, ViewContainerRef, @@ -1260,9 +1259,7 @@ describe('MDC-based MatDialog', () => { document.body.appendChild(button); button.focus(); - const dialogRef = TestBed.inject(NgZone).run(() => - dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}), - ); + const dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); flush(); viewContainerFixture.detectChanges(); @@ -1519,12 +1516,10 @@ describe('MDC-based MatDialog', () => { document.body.appendChild(button); button.focus(); - const dialogRef = TestBed.inject(NgZone).run(() => - dialog.open(PizzaMsg, { - viewContainerRef: testViewContainerRef, - restoreFocus: false, - }), - ); + const dialogRef = dialog.open(PizzaMsg, { + viewContainerRef: testViewContainerRef, + restoreFocus: false, + }); flush(); viewContainerFixture.detectChanges(); @@ -1557,9 +1552,7 @@ describe('MDC-based MatDialog', () => { body.appendChild(otherButton); button.focus(); - const dialogRef = TestBed.inject(NgZone).run(() => - dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}), - ); + const dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); flush(); viewContainerFixture.detectChanges(); diff --git a/src/material/form-field/form-field.ts b/src/material/form-field/form-field.ts index 2706d2260522..5d25bbec5d72 100644 --- a/src/material/form-field/form-field.ts +++ b/src/material/form-field/form-field.ts @@ -31,6 +31,8 @@ import { ViewChild, ViewEncapsulation, afterRender, + computed, + contentChild, inject, } from '@angular/core'; import {AbstractControlDirective} from '@angular/forms'; @@ -194,14 +196,14 @@ export class MatFormField @ViewChild(MatFormFieldNotchedOutline) _notchedOutline: MatFormFieldNotchedOutline | undefined; @ViewChild(MatFormFieldLineRipple) _lineRipple: MatFormFieldLineRipple | undefined; - @ContentChild(MatLabel) _labelChildNonStatic: MatLabel | undefined; - @ContentChild(MatLabel, {static: true}) _labelChildStatic: MatLabel | undefined; @ContentChild(_MatFormFieldControl) _formFieldControl: MatFormFieldControl; @ContentChildren(MAT_PREFIX, {descendants: true}) _prefixChildren: QueryList; @ContentChildren(MAT_SUFFIX, {descendants: true}) _suffixChildren: QueryList; @ContentChildren(MAT_ERROR, {descendants: true}) _errorChildren: QueryList; @ContentChildren(MatHint, {descendants: true}) _hintChildren: QueryList; + private readonly _labelChild = contentChild(MatLabel); + /** Whether the required marker should be hidden. */ @Input() get hideRequiredMarker(): boolean { @@ -379,9 +381,7 @@ export class MatFormField /** * Gets the id of the label element. If no label is present, returns `null`. */ - getLabelId(): string | null { - return this._hasFloatingLabel() ? this._labelId : null; - } + getLabelId = computed(() => (this._hasFloatingLabel() ? this._labelId : null)); /** * Gets an ElementRef for the element that a overlay attached to the form field @@ -551,9 +551,7 @@ export class MatFormField return !this._platform.isBrowser && this._prefixChildren.length && !this._shouldLabelFloat(); } - _hasFloatingLabel() { - return !!this._labelChildNonStatic || !!this._labelChildStatic; - } + _hasFloatingLabel = computed(() => !!this._labelChild()); _shouldLabelFloat() { return this._control.shouldLabelFloat || this._shouldAlwaysFloat(); diff --git a/src/material/select/select.spec.ts b/src/material/select/select.spec.ts index 1b8caf91b3c8..4732bab51ad8 100644 --- a/src/material/select/select.spec.ts +++ b/src/material/select/select.spec.ts @@ -1,18 +1,19 @@ +import {LiveAnnouncer} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import { + A, DOWN_ARROW, END, ENTER, + ESCAPE, HOME, LEFT_ARROW, + PAGE_DOWN, + PAGE_UP, RIGHT_ARROW, SPACE, TAB, UP_ARROW, - A, - ESCAPE, - PAGE_DOWN, - PAGE_UP, } from '@angular/cdk/keycodes'; import {OverlayContainer, OverlayModule} from '@angular/cdk/overlay'; import {ScrollDispatcher} from '@angular/cdk/scrolling'; @@ -25,24 +26,24 @@ import { } from '@angular/cdk/testing/private'; import { ChangeDetectionStrategy, + ChangeDetectorRef, Component, DebugElement, ElementRef, OnInit, + Provider, QueryList, ViewChild, ViewChildren, - Provider, - provideZoneChangeDetection, + inject, } from '@angular/core'; import { - waitForAsync, ComponentFixture, + TestBed, fakeAsync, flush, - inject, - TestBed, tick, + waitForAsync, } from '@angular/core/testing'; import { ControlValueAccessor, @@ -55,17 +56,16 @@ import { ReactiveFormsModule, Validators, } from '@angular/forms'; -import {MatOption, MatOptionSelectionChange, ErrorStateMatcher} from '@angular/material/core'; -import {MAT_SELECT_CONFIG, MatSelectConfig} from '@angular/material/select'; +import {ErrorStateMatcher, MatOption, MatOptionSelectionChange} from '@angular/material/core'; import { FloatLabelType, - MatFormFieldModule, MAT_FORM_FIELD_DEFAULT_OPTIONS, + MatFormFieldModule, } from '@angular/material/form-field'; +import {MAT_SELECT_CONFIG, MatSelectConfig} from '@angular/material/select'; import {By} from '@angular/platform-browser'; import {NoopAnimationsModule} from '@angular/platform-browser/animations'; -import {LiveAnnouncer} from '@angular/cdk/a11y'; -import {Subject, Subscription, EMPTY, Observable} from 'rxjs'; +import {EMPTY, Observable, Subject, Subscription} from 'rxjs'; import {map} from 'rxjs/operators'; import {MatSelectModule} from './index'; import {MatSelect} from './select'; @@ -79,11 +79,6 @@ import { const DEFAULT_TYPEAHEAD_DEBOUNCE_INTERVAL = 200; describe('MDC-based MatSelect', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [provideZoneChangeDetection()], - }); - }); let overlayContainerElement: HTMLElement; let dir: {value: 'ltr' | 'rtl'; change: Observable}; let scrolledSubject = new Subject(); @@ -176,6 +171,7 @@ describe('MDC-based MatSelect', () => { it('should support setting a custom aria-label', fakeAsync(() => { fixture.componentInstance.ariaLabel = 'Custom Label'; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(select.getAttribute('aria-label')).toEqual('Custom Label'); @@ -184,6 +180,7 @@ describe('MDC-based MatSelect', () => { it('should be able to add an extra aria-labelledby on top of the default', fakeAsync(() => { fixture.componentInstance.ariaLabelledby = 'myLabelId'; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); const labelId = fixture.nativeElement.querySelector('label').id; @@ -202,6 +199,7 @@ describe('MDC-based MatSelect', () => { it('should trim the trigger aria-labelledby when there is no label', fakeAsync(() => { fixture.componentInstance.hasLabel = false; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); flush(); fixture.detectChanges(); @@ -219,6 +217,7 @@ describe('MDC-based MatSelect', () => { expect(select.getAttribute('aria-describedby')).toBeNull(); fixture.componentInstance.hint = 'test'; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); const hint = fixture.debugElement.query(By.css('mat-hint')).nativeElement; expect(select.getAttribute('aria-describedby')).toBe(hint.getAttribute('id')); @@ -227,12 +226,14 @@ describe('MDC-based MatSelect', () => { it('should support user binding to `aria-describedby`', fakeAsync(() => { fixture.componentInstance.ariaDescribedBy = 'test'; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(select.getAttribute('aria-describedby')).toBe('test'); })); it('should be able to override the tabindex', fakeAsync(() => { fixture.componentInstance.tabIndexOverride = 3; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(select.getAttribute('tabindex')).toBe('3'); @@ -244,6 +245,7 @@ describe('MDC-based MatSelect', () => { .toEqual('false'); fixture.componentInstance.isRequired = true; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(select.getAttribute('aria-required')) @@ -258,6 +260,7 @@ describe('MDC-based MatSelect', () => { ); fixture.componentInstance.isRequired = true; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(select.classList) @@ -271,6 +274,8 @@ describe('MDC-based MatSelect', () => { .toEqual('false'); fixture.componentInstance.isRequired = true; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); fixture.componentInstance.control.markAsTouched(); fixture.detectChanges(); @@ -511,23 +516,23 @@ describe('MDC-based MatSelect', () => { flush(); })); - it('should announce changes via the keyboard on a closed select', fakeAsync( - inject([LiveAnnouncer], (liveAnnouncer: LiveAnnouncer) => { - spyOn(liveAnnouncer, 'announce'); + it('should announce changes via the keyboard on a closed select', fakeAsync(() => { + const liveAnnouncer = TestBed.inject(LiveAnnouncer); + spyOn(liveAnnouncer, 'announce'); - dispatchKeyboardEvent(select, 'keydown', RIGHT_ARROW); + dispatchKeyboardEvent(select, 'keydown', RIGHT_ARROW); - expect(liveAnnouncer.announce).toHaveBeenCalledWith('Steak', jasmine.any(Number)); + expect(liveAnnouncer.announce).toHaveBeenCalledWith('Steak', jasmine.any(Number)); - flush(); - }), - )); + flush(); + })); it('should not throw when reaching a reset option using the arrow keys on a closed select', fakeAsync(() => { fixture.componentInstance.foods = [ {value: 'steak-0', viewValue: 'Steak'}, {value: null, viewValue: 'None'}, ]; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); fixture.componentInstance.control.setValue('steak-0'); @@ -634,6 +639,7 @@ describe('MDC-based MatSelect', () => { const selectInstance = fixture.componentInstance.select; fixture.componentInstance.typeaheadDebounceInterval = DEFAULT_TYPEAHEAD_DEBOUNCE_INTERVAL; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(selectInstance.panelOpen) @@ -664,6 +670,7 @@ describe('MDC-based MatSelect', () => { const options = fixture.componentInstance.options.toArray(); fixture.componentInstance.typeaheadDebounceInterval = 1337; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(formControl.value).withContext('Expected no initial value.').toBeFalsy(); @@ -1135,6 +1142,7 @@ describe('MDC-based MatSelect', () => { it('should add a custom aria-labelledby to the panel', fakeAsync(() => { fixture.componentInstance.ariaLabelledby = 'myLabelId'; + fixture.changeDetectorRef.markForCheck(); fixture.componentInstance.select.open(); fixture.detectChanges(); flush(); @@ -1147,6 +1155,7 @@ describe('MDC-based MatSelect', () => { it('should trim the custom panel aria-labelledby when there is no label', fakeAsync(() => { fixture.componentInstance.hasLabel = false; fixture.componentInstance.ariaLabelledby = 'myLabelId'; + fixture.changeDetectorRef.markForCheck(); fixture.componentInstance.select.open(); fixture.detectChanges(); flush(); @@ -1158,6 +1167,7 @@ describe('MDC-based MatSelect', () => { it('should clear aria-labelledby from the panel if an aria-label is set', fakeAsync(() => { fixture.componentInstance.ariaLabel = 'My label'; + fixture.changeDetectorRef.markForCheck(); fixture.componentInstance.select.open(); fixture.detectChanges(); flush(); @@ -1299,6 +1309,7 @@ describe('MDC-based MatSelect', () => { expect(options[2].getAttribute('aria-disabled')).toEqual('true'); fixture.componentInstance.foods[2]['disabled'] = false; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(options[0].getAttribute('aria-disabled')).toEqual('false'); @@ -1532,6 +1543,7 @@ describe('MDC-based MatSelect', () => { it('should be able to set a custom width on the select panel', fakeAsync(() => { fixture.componentInstance.panelWidth = '42px'; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); trigger.click(); @@ -1544,6 +1556,7 @@ describe('MDC-based MatSelect', () => { it('should not set a width on the panel if panelWidth is null', fakeAsync(() => { fixture.componentInstance.panelWidth = null; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); trigger.click(); @@ -1556,6 +1569,7 @@ describe('MDC-based MatSelect', () => { it('should not set a width on the panel if panelWidth is an empty string', fakeAsync(() => { fixture.componentInstance.panelWidth = ''; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); trigger.click(); @@ -1568,6 +1582,7 @@ describe('MDC-based MatSelect', () => { it('should not attempt to open a select that does not have any options', fakeAsync(() => { fixture.componentInstance.foods = []; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); trigger.click(); @@ -1703,6 +1718,7 @@ describe('MDC-based MatSelect', () => { .toBeTruthy(); fixture.componentInstance.disableRipple = true; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(options.every(option => option.disableRipple === true)) @@ -1712,6 +1728,7 @@ describe('MDC-based MatSelect', () => { it('should not show ripples if they were disabled', fakeAsync(() => { fixture.componentInstance.disableRipple = true; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); trigger.click(); @@ -1919,6 +1936,7 @@ describe('MDC-based MatSelect', () => { .toBe(select.options.first); fixture.componentInstance.foods = []; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); flush(); @@ -1987,6 +2005,7 @@ describe('MDC-based MatSelect', () => { expect(trigger.textContent!.trim()).toBe('Pizza'); fixture.componentInstance.foods[1].viewValue = 'Calzone'; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(trigger.textContent!.trim()).toBe('Calzone'); @@ -1999,6 +2018,7 @@ describe('MDC-based MatSelect', () => { expect(trigger.textContent!.trim()).toBe('Pizza'); fixture.componentInstance.capitalize = true; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); fixture.checkNoChanges(); @@ -2104,12 +2124,14 @@ describe('MDC-based MatSelect', () => { }; fixture.componentInstance.foods = [{value: 'salad-8', viewValue: 'Salad'}]; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); selectFirstOption(); expect(spy).toHaveBeenCalledTimes(1); fixture.componentInstance.foods = [{value: 'fruit-9', viewValue: 'Fruit'}]; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); selectFirstOption(); @@ -2145,6 +2167,7 @@ describe('MDC-based MatSelect', () => { it('should take an initial view value with reactive forms', fakeAsync(() => { fixture.componentInstance.control = new FormControl('pizza-1'); + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); const value = fixture.debugElement.query(By.css('.mat-mdc-select-value'))!; @@ -2368,6 +2391,7 @@ describe('MDC-based MatSelect', () => { .toBeFalsy(); fixture.componentInstance.isRequired = true; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(label.querySelector('.mat-mdc-form-field-required-marker')) @@ -2381,6 +2405,7 @@ describe('MDC-based MatSelect', () => { expect(control.value).toBeFalsy(); fixture.componentInstance.select.value = 'pizza-1'; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(control.value).toBe('pizza-1'); @@ -2458,6 +2483,7 @@ describe('MDC-based MatSelect', () => { fixture.componentInstance.foods.push({value: `value-${i}`, viewValue: `Option ${i}`}); } + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); fixture.componentInstance.select.open(); fixture.detectChanges(); @@ -2726,6 +2752,7 @@ describe('MDC-based MatSelect', () => { fixture.detectChanges(); fixture.componentInstance.isDisabled = true; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); flush(); @@ -2746,6 +2773,7 @@ describe('MDC-based MatSelect', () => { .toBe(false); fixture.componentInstance.isDisabled = false; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); flush(); @@ -2774,6 +2802,7 @@ describe('MDC-based MatSelect', () => { fixture.detectChanges(); fixture.componentInstance.isShowing = true; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); const formField = fixture.debugElement.query(By.css('.mat-mdc-form-field'))!.nativeElement; @@ -2781,8 +2810,8 @@ describe('MDC-based MatSelect', () => { formField.style.width = '300px'; trigger.click(); - fixture.detectChanges(); flush(); + fixture.detectChanges(); const value = fixture.debugElement.query(By.css('.mat-mdc-select-value'))!; expect(value.nativeElement.textContent) @@ -2857,6 +2886,7 @@ describe('MDC-based MatSelect', () => { expect(fixture.componentInstance.control.value).toBeFalsy(); fixture.componentInstance.floatLabel = 'always'; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(label.classList.contains('mdc-floating-label--float-above')) @@ -2880,6 +2910,7 @@ describe('MDC-based MatSelect', () => { const fixture = TestBed.createComponent(FloatLabelSelect); fixture.componentInstance.floatLabel = null; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); const label = fixture.nativeElement.querySelector('.mat-mdc-form-field label'); @@ -2894,6 +2925,7 @@ describe('MDC-based MatSelect', () => { expect(fixture.componentInstance.placeholder).toBeTruthy(); fixture.componentInstance.floatLabel = 'auto'; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); dispatchFakeEvent(fixture.nativeElement.querySelector('.mat-mdc-select'), 'focus'); @@ -2961,6 +2993,7 @@ describe('MDC-based MatSelect', () => { const trigger = formField.querySelector('.mat-mdc-select-trigger'); formField.style.width = '300px'; fixture.componentInstance.isVisible = true; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); trigger.click(); @@ -3002,6 +3035,7 @@ describe('MDC-based MatSelect', () => { it('should transfer the theme to the select panel', fakeAsync(() => { fixture.componentInstance.theme = 'warn'; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); fixture.componentInstance.select.open(); @@ -3020,6 +3054,7 @@ describe('MDC-based MatSelect', () => { // The first change detection run will throw the "ngModel is missing a name" error. expect(() => fixture.detectChanges()).toThrowError(/the name attribute must be set/g); + fixture.changeDetectorRef.markForCheck(); // The second run shouldn't throw selection-model related errors. expect(() => fixture.detectChanges()).not.toThrow(); @@ -3269,6 +3304,7 @@ describe('MDC-based MatSelect', () => { const subscription = testComponent.select.stateChanges.subscribe(spy); testComponent.options = []; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); tick(); @@ -3296,6 +3332,7 @@ describe('MDC-based MatSelect', () => { expect(component.select.errorState).toBe(false); fixture.componentInstance.errorStateMatcher = {isErrorState: matcher}; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(component.select.errorState).toBe(true); @@ -3616,6 +3653,7 @@ describe('MDC-based MatSelect', () => { fixture.detectChanges(); fixture.componentInstance.selectedFood = 'sandwich-2'; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); const trigger = fixture.debugElement.query(By.css('.mat-mdc-select-trigger'))!.nativeElement; @@ -3651,6 +3689,7 @@ describe('MDC-based MatSelect', () => { expect(trigger.textContent).toContain('Steak'); fixture.componentInstance.selectedFood = null; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(fixture.componentInstance.select.value).toBeNull(); @@ -4162,6 +4201,7 @@ describe('MDC-based MatSelect', () => { fixture.componentInstance.sortComparator = (a, b, optionsArray) => { return optionsArray.indexOf(b) - optionsArray.indexOf(a); }; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); trigger.click(); @@ -4255,6 +4295,7 @@ describe('MDC-based MatSelect', () => { {value: 'pizza-1', viewValue: 'Pizza'}, {value: null, viewValue: 'Tacos'}, ]; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); trigger.click(); @@ -4506,6 +4547,7 @@ describe('MDC-based MatSelect', () => { const select = fixture.componentInstance.select; fixture.componentInstance.select.value = fixture.componentInstance.foods[0].value; + fixture.changeDetectorRef.markForCheck(); select.open(); fixture.detectChanges(); @@ -5470,6 +5512,8 @@ class SelectInsideDynamicFormGroup { @ViewChild(MatSelect) select: MatSelect; form: FormGroup; + private readonly _changeDetectorRef = inject(ChangeDetectorRef); + constructor(private _formBuilder: FormBuilder) { this.assignGroup(false); } @@ -5478,6 +5522,7 @@ class SelectInsideDynamicFormGroup { this.form = this._formBuilder.group({ control: {value: '', disabled: isDisabled}, }); + this._changeDetectorRef.markForCheck(); } } @Component({ diff --git a/tools/public_api_guard/material/form-field.md b/tools/public_api_guard/material/form-field.md index 158729039b2f..e7c56be82e1c 100644 --- a/tools/public_api_guard/material/form-field.md +++ b/tools/public_api_guard/material/form-field.md @@ -24,6 +24,7 @@ import { Observable } from 'rxjs'; import { OnDestroy } from '@angular/core'; import { Platform } from '@angular/cdk/platform'; import { QueryList } from '@angular/core'; +import { Signal } from '@angular/core'; import { ThemePalette } from '@angular/material/core'; // @public @@ -90,10 +91,10 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte _formFieldControl: MatFormFieldControl_2; getConnectedOverlayOrigin(): ElementRef; _getDisplayedMessages(): 'error' | 'hint'; - getLabelId(): string | null; + getLabelId: Signal; _handleLabelResized(): void; // (undocumented) - _hasFloatingLabel(): boolean; + _hasFloatingLabel: Signal; // (undocumented) _hasIconPrefix: boolean; // (undocumented) @@ -115,10 +116,6 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte // (undocumented) _iconPrefixContainer: ElementRef; // (undocumented) - _labelChildNonStatic: MatLabel | undefined; - // (undocumented) - _labelChildStatic: MatLabel | undefined; - // (undocumented) readonly _labelId: string; // (undocumented) _lineRipple: MatFormFieldLineRipple | undefined; @@ -149,7 +146,7 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte // (undocumented) _textPrefixContainer: ElementRef; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } diff --git a/tools/tslint-rules/noZoneDependenciesRule.ts b/tools/tslint-rules/noZoneDependenciesRule.ts index 5f050b9a198c..8fd1b3743820 100644 --- a/tools/tslint-rules/noZoneDependenciesRule.ts +++ b/tools/tslint-rules/noZoneDependenciesRule.ts @@ -21,24 +21,36 @@ class Walker extends Lint.RuleWalker { private _enabled: boolean; constructor( - sourceFile: ts.SourceFile, + private _sourceFile: ts.SourceFile, options: Lint.IOptions, private _typeChecker: ts.TypeChecker, ) { - super(sourceFile, options); + super(_sourceFile, options); // Globs that are used to determine which files to lint. const fileGlobs: string[] = options.ruleArguments[0]; // Whether the file should be checked at all. - this._enabled = !fileGlobs.some(p => minimatch(sourceFile.fileName, p)); + this._enabled = !fileGlobs.some(p => minimatch(_sourceFile.fileName, p)); } - override visitPropertyAccessExpression(node: ts.PropertyAccessExpression) { + override visitIdentifier(node: ts.Identifier): void { if (!this._enabled) { return; } + const symbol = this._typeChecker.getSymbolAtLocation(node); + const decl = symbol?.valueDeclaration; + if (decl && ts.isVariableDeclaration(decl) && decl.name.getText() === 'Zone') { + this.addFailureAtNode(node, `Using Zone is not allowed.`); + } + } + + override visitPropertyAccessExpression(node: ts.PropertyAccessExpression) { + if (!this._enabled || this._sourceFile.fileName.endsWith('.spec.ts')) { + return; + } + const classType = this._typeChecker.getTypeAtLocation(node.expression); const className = classType.symbol && classType.symbol.name; const propertyName = node.name.text; @@ -60,5 +72,17 @@ class Walker extends Lint.RuleWalker { this.addFailureAtNode(specifier, `Using zone change detection is not allowed.`); } }); + + if (this._sourceFile.fileName.endsWith('.spec.ts')) { + node.elements.forEach(specifier => { + if (specifier.name.getText() === 'NgZone' && !specifier.isTypeOnly) { + this.addFailureAtNode( + specifier, + `Using NgZone is not allowed in zoneless tests. Tests that explicitly test Zone.js` + + ` integration should go in .zone.spec.ts files.`, + ); + } + }); + } } } diff --git a/tslint.json b/tslint.json index 44b6eb818a7a..37b54da9a6d9 100644 --- a/tslint.json +++ b/tslint.json @@ -187,9 +187,11 @@ [ // Allow in tests that specficially test integration with Zone.js. "**/*.zone.spec.ts", - // TODO(mmalerba): following files to be cleaned up and removed from this list: + // TODO: Test harnesses infrastructure still relies on Zone.js. + "**/src/cdk/testing/testbed/task-state-zone-interceptor.ts", + "**/src/cdk/testing/testbed/testbed-harness-environment.ts", "**/src/cdk/testing/tests/testbed.spec.ts", - "**/src/material/select/**/*.spec.ts", + // TODO: Tooltip harness still relies on Zone.js. "**/src/material/tooltip/testing/tooltip-harness.spec.ts" ] ]