Search code examples
angularangular-cdkangular-cdk-virtual-scroll

Obtaining a static component reference within a cdk-virtual-scroller? (References are recycled)


We recently transitioned our scrollable lists to CDK Virtual Scroller. (Angular 7.2.12 with angular/cdk 7.3.7)

In short, it seems that the VirtualScrollViewport is recycling component instances, not just the template as the documentation suggests.

Live MCVE on StackBlitz (updated to reflect EDIT 1).

EDIT 1

A colleague reminded me that we're now using named references instead of ViewChildren(), like so:

HelloComponent (inside the *cdkVirtualFor):

@Component({
  selector: 'hello',
  template: `<h1 [class.active]="active">Data Item {{item}} !</h1>`,
  styles: [`.active {background-color: red; color: white}`]
})
export class HelloComponent  {
  @Input() item: any;
  active: boolean = false;
  toggle = () => this.active = !this.active;
}

And implementing it in the App like:

<cdk-virtual-scroll-viewport itemSize="75">
  <ng-container *cdkVirtualFor="let item of data" templateCacheSize=0>
    <hello #hi [item]="item" (click)="clickByReference(hi)"></hello>
  </ng-container>
</cdk-virtual-scroll-viewport>

// Non-essentials hidden, see StackBlitz
export class AppComponent  {
  data = Array.from(Array(100).keys())
  clickByReference = (element: any): void => element.toggle();
}

It will change the background colour of the clicked element to red, but when scrolling, others (presumably those that match some cached index?) will already be red! Activating one of those will clear the original as well.

The source suggests that templateCacheSize might help, but it doesn't.

Original

The scrollable area contains components which we get a reference to with a @ViewChildren() and QueryList and we track which one we are acting on using an index in the *ngFor (now *cdkVirtualFor), like so:

<cdk-virtual-scroll-viewport itemSize="75">
  <ng-container *cdkVirtualFor="let item of data; let i = index">
    <hello  #hi
            [item]="item"
            (click)="click(i)"></hello>
  </ng-container>
</cdk-virtual-scroll-viewport>

Then, from the page, we communicate with the component in the list:

export class AppComponent  {
  @ViewChildren('hi') hiRefs: QueryList<HelloComponent>;
  data = Array.from(Array(100).keys())

  click = (i: number) => this.hiRefs["_results"][i].say(`Hello as ${i}`);
}

Of course, now that the template is rendered in a virtual scroll container, only the first n are rendered into the DOM. So if you scroll down the list beyond what is initially loaded, hiRefs does not contain a reference to the item with the corresponding index, throwing a ReferenceError for the provided ["_results"][i].

I experimented with trackBy but didn't get anything fruitful.

EDIT: A colleague has also attempted to pass a named reference, which curiously has the same problem.

Updating the HelloComponent to

@Component({
  selector: 'hello',
  template: `<h1 [class.active]="active">Data Item {{item}} !</h1>`,
  styles: [`.active {background-color: red}`]
})
export class HelloComponent  {
  @Input() item: any;
  active: boolean;

  say = (something: any) => this.active = !this.active;
}

And implementing it in the App like:

<hello #hi [item]="item" (click)="clickByReference(hi)"></hello>

It will change the background colour of the clicked element to red, but when scrolling, others (presumably those that match the same index) will already be red, despite not using the @ViewChildren() QueryList at all!

It seems that the CDK is recycling component instance references?

I updated the StackBlitz with the method clickByReference(), and renamed the one above to clickByIndex().

How can I correctly get a reference to the component in the list in order to call methods on it?


Solution

  • By default, CdkVirtualForOf caches 20 ViewRefs to components that are no longer rendered into the DOM to improve scrolling performance.

    While these update to show new bound @Input()s, they do not update their internal state, so previously-cached copies are re-used as a result.

    It seems the only solution is to set templateCacheSize: 0:

    <ng-container *cdkVirtualFor="let item of data; templateCacheSize: 0">
    

    That way the components are destroyed once they're no longer visible, and state is lost.

    Further reading https://github.com/angular/material2/issues/15838 and a doc PR.