diff --git a/components/select/select.component.ts b/components/select/select.component.ts index af84d1def47..eaf1732a557 100644 --- a/components/select/select.component.ts +++ b/components/select/select.component.ts @@ -37,7 +37,7 @@ import { startWith, switchMap, takeUntil } from 'rxjs/operators'; import { slideMotion } from 'ng-zorro-antd/core/animation'; import { NzConfigKey, NzConfigService, WithConfig } from 'ng-zorro-antd/core/config'; import { NzNoAnimationDirective } from 'ng-zorro-antd/core/no-animation'; -import { reqAnimFrame } from 'ng-zorro-antd/core/polyfill'; +import { cancelRequestAnimationFrame, reqAnimFrame } from 'ng-zorro-antd/core/polyfill'; import { NzDestroyService } from 'ng-zorro-antd/core/services'; import { BooleanInput, NzSafeAny, OnChangeType, OnTouchedType } from 'ng-zorro-antd/core/types'; import { InputBoolean, isNotNil } from 'ng-zorro-antd/core/util'; @@ -242,6 +242,7 @@ export class NzSelectComponent implements ControlValueAccessor, OnInit, AfterCon private isReactiveDriven = false; private value: NzSafeAny | NzSafeAny[]; private _nzShowArrow: boolean | undefined; + private requestId: number = -1; onChange: OnChangeType = () => {}; onTouched: OnTouchedType = () => {}; dropDownPosition: 'top' | 'center' | 'bottom' = 'bottom'; @@ -491,9 +492,18 @@ export class NzSelectComponent implements ControlValueAccessor, OnInit, AfterCon updateCdkConnectedOverlayStatus(): void { if (this.platform.isBrowser && this.originElement.nativeElement) { - reqAnimFrame(() => { + const triggerWidth = this.triggerWidth; + cancelRequestAnimationFrame(this.requestId); + this.requestId = reqAnimFrame(() => { + // Blink triggers style and layout pipelines anytime the `getBoundingClientRect()` is called, which may cause a + // frame drop. That's why it's scheduled through the `requestAnimationFrame` to unload the composite thread. this.triggerWidth = this.originElement.nativeElement.getBoundingClientRect().width; - this.cdr.markForCheck(); + if (triggerWidth !== this.triggerWidth) { + // The `requestAnimationFrame` will trigger change detection, but we're inside an `OnPush` component which won't have + // the `ChecksEnabled` state. Calling `markForCheck()` will allow Angular to run the change detection from the root component + // down to the `nz-select`. But we'll trigger only local change detection if the `triggerWidth` has been changed. + this.cdr.detectChanges(); + } }); } } @@ -672,6 +682,7 @@ export class NzSelectComponent implements ControlValueAccessor, OnInit, AfterCon } } ngOnDestroy(): void { + cancelRequestAnimationFrame(this.requestId); this.focusMonitor.stopMonitoring(this.elementRef); } } diff --git a/components/select/select.spec.ts b/components/select/select.spec.ts index c6f951c6af9..377fcedd7f4 100644 --- a/components/select/select.spec.ts +++ b/components/select/select.spec.ts @@ -1,7 +1,7 @@ import { BACKSPACE, DOWN_ARROW, ENTER, ESCAPE, SPACE, TAB, UP_ARROW } from '@angular/cdk/keycodes'; import { OverlayContainer } from '@angular/cdk/overlay'; import { Component, TemplateRef, ViewChild } from '@angular/core'; -import { ComponentFixture, fakeAsync, flush, inject } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, flush, inject, tick } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; @@ -1180,6 +1180,50 @@ describe('select', () => { expect(listOfItem[2].textContent).toBe('and 2 more selected'); })); }); + describe('change detection', () => { + let testBed: ComponentBed; + let component: TestSelectTemplateDefaultComponent; + let fixture: ComponentFixture; + let selectComponent: NzSelectComponent; + let overlayContainerElement: HTMLElement; + + beforeEach(() => { + testBed = createComponentBed(TestSelectTemplateDefaultComponent, { + imports: [NzSelectModule, NzIconTestModule, FormsModule] + }); + component = testBed.component; + fixture = testBed.fixture; + selectComponent = testBed.debugElement.query(By.directive(NzSelectComponent)).componentInstance; + }); + + beforeEach(inject([OverlayContainer], (oc: OverlayContainer) => { + overlayContainerElement = oc.getContainerElement(); + })); + + it('should not run change detection if the `triggerWidth` has not been changed', fakeAsync(() => { + const detectChangesSpy = spyOn(selectComponent['cdr'], 'detectChanges').and.callThrough(); + const requestAnimationFrameSpy = spyOn(window, 'requestAnimationFrame').and.callThrough(); + + component.nzOpen = true; + fixture.detectChanges(); + // The `requestAnimationFrame` is simulated as `setTimeout(..., 16)` inside the `fakeAsync`. + tick(16); + + dispatchKeyboardEvent(overlayContainerElement, 'keydown', ESCAPE, overlayContainerElement); + fixture.detectChanges(); + flush(); + + expect(component.nzOpen).toEqual(false); + + component.nzOpen = true; + fixture.detectChanges(); + tick(16); + + // Ensure that the `detectChanges()` have been called only once since the `triggerWidth` hasn't been changed. + expect(detectChangesSpy).toHaveBeenCalledTimes(1); + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(2); + })); + }); }); @Component({