From 87b862f87136afb9f6e94816f2e0cc54e5fbbf03 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Wed, 28 May 2025 07:35:53 -0700 Subject: [PATCH 1/2] fix(cdk/scrolling): Prevent virtual scroll 'flickering' with zoneless This fixes the perceived flickering due to the transform sometimes becoming visible with zoneless due to the macrotask-based scheduler. The transform cannot be simply moved inside `afterNextRender` because it causes differences in timing that can break certain scroll implementations (http://b/335066372). This approach fiddles with the details of `ngZone.run` and avoiding unnecessary extra ApplicationRef.tick calls. We could instead put everything inside an ngZone.run and call ApplicationRef.tick in there, but that would result in a second tick when the `ngZone.run` exits. Fixes #29174 --- src/cdk/scrolling/virtual-scroll-viewport.ts | 53 +++++++++++++------- 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/src/cdk/scrolling/virtual-scroll-viewport.ts b/src/cdk/scrolling/virtual-scroll-viewport.ts index 693c3cd92553..10d6c02d260c 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk/scrolling/virtual-scroll-viewport.ts @@ -10,6 +10,7 @@ import {ListRange} from '../collections'; import {Platform} from '../platform'; import { afterNextRender, + ApplicationRef, booleanAttribute, ChangeDetectionStrategy, ChangeDetectorRef, @@ -181,6 +182,8 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On private _injector = inject(Injector); + private readonly _applicationRef = inject(ApplicationRef); + private _isDestroyed = false; constructor(...args: unknown[]); @@ -506,29 +509,45 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On return; } + // Apply the content transform. The transform can't be set via an Angular binding because + // bypassSecurityTrustStyle is banned in Google. However the value is safe, it's composed of + // string literals, a variable that can only be 'X' or 'Y', and user input that is run through + // the `Number` function first to coerce it to a numeric value. + this._contentWrapper.nativeElement.style.transform = this._renderedContentTransform; + + let rendered = false; + afterNextRender( + () => { + this._isChangeDetectionPending = false; + const runAfterChangeDetection = this._runAfterChangeDetection; + this._runAfterChangeDetection = []; + for (const fn of runAfterChangeDetection) { + fn(); + } + rendered = true; + }, + {injector: this._injector}, + ); + this.ngZone.run(() => { // Apply changes to Angular bindings. Note: We must call `markForCheck` to run change detection // from the root, since the repeated items are content projected in. Calling `detectChanges` // instead does not properly check the projected content. this._changeDetectorRef.markForCheck(); + }); - // Apply the content transform. The transform can't be set via an Angular binding because - // bypassSecurityTrustStyle is banned in Google. However the value is safe, it's composed of - // string literals, a variable that can only be 'X' or 'Y', and user input that is run through - // the `Number` function first to coerce it to a numeric value. - this._contentWrapper.nativeElement.style.transform = this._renderedContentTransform; - - afterNextRender( - () => { - this._isChangeDetectionPending = false; - const runAfterChangeDetection = this._runAfterChangeDetection; - this._runAfterChangeDetection = []; - for (const fn of runAfterChangeDetection) { - fn(); - } - }, - {injector: this._injector}, - ); + // In applications with NgZone, the above NgZone.run is likely to cause synchronous ApplicationRef.tick + // because we execute this function outside the zone and run coalescing is usually off. + // App synchronization needs to happen within the same microtask loop after applying the transform. + // Otherwise, the transform can become visible and look like a "flicker" when scrolling due to + // potential delays between the browser paint and the next tick. + this.ngZone.runOutsideAngular(async () => { + await Promise.resolve(); + if (!rendered && this._runAfterChangeDetection.length > 0) { + this.ngZone.run(() => { + this._applicationRef.tick(); + }); + } }); } From e9d1fa171fd83c97a3fc63b26bf56b840ae47369 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Tue, 3 Jun 2025 13:25:39 -0700 Subject: [PATCH 2/2] fixup! fix(cdk/scrolling): Prevent virtual scroll 'flickering' with zoneless --- src/cdk/scrolling/virtual-scroll-viewport.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/cdk/scrolling/virtual-scroll-viewport.ts b/src/cdk/scrolling/virtual-scroll-viewport.ts index 10d6c02d260c..fa67b7bb3dd2 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk/scrolling/virtual-scroll-viewport.ts @@ -543,11 +543,18 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On // potential delays between the browser paint and the next tick. this.ngZone.runOutsideAngular(async () => { await Promise.resolve(); - if (!rendered && this._runAfterChangeDetection.length > 0) { - this.ngZone.run(() => { - this._applicationRef.tick(); - }); + if ( + rendered || + this._runAfterChangeDetection.length === 0 || + // shouldn't be possible since we run this asynchronously and tick is synchronous, but ZoneJS/fakeAsync + // can flush microtasks synchronously + (this._applicationRef as unknown as {_runningTick: boolean})._runningTick + ) { + return; } + this.ngZone.run(() => { + this._applicationRef.tick(); + }); }); }