From 745eda922e2dca6eec36d38fbbe316218d5b7353 Mon Sep 17 00:00:00 2001 From: arturovt Date: Fri, 14 Jun 2024 20:35:30 +0300 Subject: [PATCH] fix(module:icon): debounce icon rendering on animation frame --- components/icon/icon.directive.ts | 103 ++++++++++++++++++------------ 1 file changed, 61 insertions(+), 42 deletions(-) diff --git a/components/icon/icon.directive.ts b/components/icon/icon.directive.ts index 2f8d415aa2f..f3e734fee09 100644 --- a/components/icon/icon.directive.ts +++ b/components/icon/icon.directive.ts @@ -3,6 +3,7 @@ * found in the LICENSE file at https://github.com/NG-ZORRO/ng-zorro-antd/blob/master/LICENSE */ +import { isPlatformBrowser } from '@angular/common'; import { AfterContentChecked, ChangeDetectorRef, @@ -11,16 +12,16 @@ import { Input, NgZone, OnChanges, - OnDestroy, OnInit, Renderer2, SimpleChanges, booleanAttribute, + numberAttribute, + ExperimentalPendingTasks, inject, - numberAttribute + DestroyRef, + PLATFORM_ID } from '@angular/core'; -import { Subject, from } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; import { IconDirective, ThemeType } from '@ant-design/icons-angular'; @@ -36,7 +37,7 @@ import { NzIconPatchService, NzIconService } from './icon.service'; }, standalone: true }) -export class NzIconDirective extends IconDirective implements OnInit, OnChanges, AfterContentChecked, OnDestroy { +export class NzIconDirective extends IconDirective implements OnInit, OnChanges, AfterContentChecked { cacheClassName: string | null = null; @Input({ transform: booleanAttribute }) set nzSpin(value: boolean) { @@ -71,7 +72,11 @@ export class NzIconDirective extends IconDirective implements OnInit, OnChanges, private iconfont?: string; private spin: boolean = false; - private destroy$ = new Subject(); + private destroyRef = inject(DestroyRef); + private pendingTasks = inject(ExperimentalPendingTasks); + private isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + + private destroyed = false; constructor( private readonly ngZone: NgZone, @@ -82,6 +87,10 @@ export class NzIconDirective extends IconDirective implements OnInit, OnChanges, ) { super(iconService, elementRef, renderer); + this.destroyRef.onDestroy(() => { + this.destroyed = true; + }); + const iconPatch = inject(NzIconPatchService, { optional: true }); if (iconPatch) { iconPatch.doPatch(); @@ -94,7 +103,9 @@ export class NzIconDirective extends IconDirective implements OnInit, OnChanges, const { nzType, nzTwotoneColor, nzSpin, nzTheme, nzRotate } = changes; if (nzType || nzTwotoneColor || nzSpin || nzTheme) { - this.changeIcon2(); + // This is used to reduce the number of change detections + // while the icon is being loaded asynchronously. + this.ngZone.runOutsideAngular(() => this.changeIcon2()); } else if (nzRotate) { this.handleRotate(this.el.firstChild as SVGElement); } else { @@ -124,46 +135,54 @@ export class NzIconDirective extends IconDirective implements OnInit, OnChanges, } } - ngOnDestroy(): void { - this.destroy$.next(); - } - /** * Replacement of `changeIcon` for more modifications. */ - private changeIcon2(): void { + private async changeIcon2(): Promise { this.setClassName(); - // The Angular zone is left deliberately before the SVG is set - // since `_changeIcon` spawns asynchronous tasks as promise and - // HTTP calls. This is used to reduce the number of change detections - // while the icon is being loaded dynamically. - this.ngZone.runOutsideAngular(() => { - from(this._changeIcon()) - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: svgOrRemove => { - // Get back into the Angular zone after completing all the tasks. - // Since we manually run change detection locally, we have to re-enter - // the zone because the change detection might also be run on other local - // components, leading them to handle template functions outside of the Angular zone. - this.ngZone.run(() => { - // The _changeIcon method would call Renderer to remove the element of the old icon, - // which would call `markElementAsRemoved` eventually, - // so we should call `detectChanges` to tell Angular remove the DOM node. - // #7186 - this.changeDetectorRef.detectChanges(); - - if (svgOrRemove) { - this.setSVGData(svgOrRemove); - this.handleSpin(svgOrRemove); - this.handleRotate(svgOrRemove); - } - }); - }, - error: warn - }); - }); + // It is used to hydrate the icon component property when + // zoneless change detection is used in conjunction with server-side rendering. + const removeTask = this.pendingTasks.add(); + + try { + const svgOrRemove = await this._changeIcon(); + + if (this.isBrowser) { + // We need to individually debounce the icon rendering on each animation + // frame to prevent frame drops when many icons are being rendered on the + // page, such as in a `@for` loop. + await new Promise(resolve => requestAnimationFrame(resolve)); + } + + // If the icon view has been destroyed by the time the promise resolves, + // we simply exit. + if (this.destroyed) { + return; + } + + // Get back into the Angular zone after completing all the tasks. + // Since we manually run change detection locally, we have to re-enter + // the zone because the change detection might also be run on other local + // components, leading them to handle template functions outside of the Angular zone. + this.ngZone.run(() => { + // The _changeIcon method would call Renderer to remove the element of the old icon, + // which would call `markElementAsRemoved` eventually, + // so we should call `detectChanges` to tell Angular remove the DOM node. + // #7186 + this.changeDetectorRef.detectChanges(); + + if (svgOrRemove) { + this.setSVGData(svgOrRemove); + this.handleSpin(svgOrRemove); + this.handleRotate(svgOrRemove); + } + }); + } catch (error) { + warn(error); + } finally { + removeTask(); + } } private handleSpin(svg: SVGElement): void {