Skip to content

fix(cdk/scrolling): Prevent virtual scroll 'flickering' with zoneless #31316

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 62 additions & 69 deletions src/cdk/scrolling/virtual-scroll-viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ import {
signal,
ViewChild,
ViewEncapsulation,
ApplicationRef,
effect,
linkedSignal,
} from '@angular/core';
import {
animationFrameScheduler,
Expand Down Expand Up @@ -170,19 +173,16 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On
*/
private _renderedContentOffsetNeedsRewrite = false;

/** Whether there is a pending change detection cycle. */
private _isChangeDetectionPending = false;

/** A list of functions to run after the next change detection cycle. */
private _runAfterChangeDetection: Function[] = [];
private _runAfterChangeDetection = signal<Function[]>([]);

/** Subscription to changes in the viewport size. */
private _viewportChanges = Subscription.EMPTY;

private _injector = inject(Injector);

private _isDestroyed = false;

private _changeDetectionNeeded = linkedSignal(() => this._runAfterChangeDetection().length > 0);

constructor(...args: unknown[]);

constructor() {
Expand All @@ -202,6 +202,41 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On
this.elementRef.nativeElement.classList.add('cdk-virtual-scrollable');
this.scrollable = this;
}

const injector = inject(ApplicationRef).injector;
effect(
() => {
if (!this._changeDetectionNeeded() || this._isDestroyed) {
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;

// 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();

afterNextRender(
{
mixedReadWrite: () => {
// TODO: should this be done at the top?
const runAfterChangeDetection = this._runAfterChangeDetection();
this._runAfterChangeDetection.set([]);
for (const fn of runAfterChangeDetection) {
fn();
}
},
},
{injector},
);
},
{injector},
);
}

override ngOnInit() {
Expand Down Expand Up @@ -238,7 +273,7 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On
)
.subscribe(() => this._scrollStrategy.onContentScrolled());

this._markChangeDetectionNeeded();
this._changeDetectionNeeded.set(true);
}),
);
}
Expand Down Expand Up @@ -274,7 +309,7 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On
this._dataLength = newLength;
this._scrollStrategy.onDataLengthChanged();
}
this._doChangeDetection();
this._changeDetectionNeeded.set(true);
});
});
}
Expand Down Expand Up @@ -317,7 +352,7 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On
if (this._totalContentSize !== size) {
this._totalContentSize = size;
this._calculateSpacerSize();
this._markChangeDetectionNeeded();
this._changeDetectionNeeded.set(true);
}
}

Expand All @@ -328,7 +363,11 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On
range = {start: 0, end: Math.max(this._renderedRange.end, range.end)};
}
this._renderedRangeSubject.next((this._renderedRange = range));
this._markChangeDetectionNeeded(() => this._scrollStrategy.onContentRendered());
this._runAfterChangeDetection.update(v => [
...v,
() => this._scrollStrategy.onContentRendered(),
]);
this._changeDetectionNeeded.set(true);
}
}

Expand Down Expand Up @@ -366,15 +405,19 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On
// We know this value is safe because we parse `offset` with `Number()` before passing it
// into the string.
this._renderedContentTransform = transform;
this._markChangeDetectionNeeded(() => {
if (this._renderedContentOffsetNeedsRewrite) {
this._renderedContentOffset -= this.measureRenderedContentSize();
this._renderedContentOffsetNeedsRewrite = false;
this.setRenderedContentOffset(this._renderedContentOffset);
} else {
this._scrollStrategy.onRenderedOffsetChanged();
}
});
this._runAfterChangeDetection.update(v => [
...v,
() => {
if (this._renderedContentOffsetNeedsRewrite) {
this._renderedContentOffset -= this.measureRenderedContentSize();
this._renderedContentOffsetNeedsRewrite = false;
this.setRenderedContentOffset(this._renderedContentOffset);
} else {
this._scrollStrategy.onRenderedOffsetChanged();
}
},
]);
this._changeDetectionNeeded.set(true);
}
}

Expand Down Expand Up @@ -482,56 +525,6 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On
this._viewportSize = this.scrollable.measureViewportSize(this.orientation);
}

/** Queue up change detection to run. */
private _markChangeDetectionNeeded(runAfter?: Function) {
if (runAfter) {
this._runAfterChangeDetection.push(runAfter);
}

// Use a Promise to batch together calls to `_doChangeDetection`. This way if we set a bunch of
// properties sequentially we only have to run `_doChangeDetection` once at the end.
if (!this._isChangeDetectionPending) {
this._isChangeDetectionPending = true;
this.ngZone.runOutsideAngular(() =>
Promise.resolve().then(() => {
this._doChangeDetection();
}),
);
}
}

/** Run change detection. */
private _doChangeDetection() {
if (this._isDestroyed) {
return;
}

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},
);
});
}

/** Calculates the `style.width` and `style.height` for the spacer element. */
private _calculateSpacerSize() {
this._totalContentHeight.set(
Expand Down
Loading