components icon indicating copy to clipboard operation
components copied to clipboard

MatTree does not update items when using trackBy

Open matthiaswelz opened this issue 4 years ago • 12 comments

When using trackBy, CdkTree / MatTree does not update items which share the same identity (as returned by trackBy), but are actually different obejcts (with property changes). This behavior differs from the behavior shown by ngFor in combination with trackBy.

Reproduction

StackBlitz: https://stackblitz.com/edit/angular-cfph4s

Steps to reproduce:

  1. Click the button

Expected Behavior

The tree gets updated as the list created by ngFor does.

Actual Behavior

The tree does not change, only the list generated by ngFor.

Environment

  • Angular: 9.0.1
  • CDK/Material: 9.1.0
  • Browser(s): Chrome
  • Operating System (e.g. Windows, macOS, Ubuntu): macOS

matthiaswelz avatar Feb 27 '20 18:02 matthiaswelz

Thanks for this issue. This seems like a valid bug to me. The NgForOf directive also respects the identity, correct. See:

https://cs.opensource.google/angular/angular/+/master:packages/common/src/directives/ng_for_of.ts;l=260-264?q=NgFor&ss=angular

We should probably add the same to the tree here, but not sure if it would be considered breaking.

devversion avatar Aug 07 '20 11:08 devversion

Any news on this one?

I ran into the same issue and quick and dirty fixed it by using a custom tree component.

I think, we have to get the instance of MatTreeNode/CdkTreeNode and set the data property, but I don't know how to get it. So I just ended up overriding the outlet context.

@Component({
  selector: 'app-fixed-tree',
  template: '<ng-container matTreeNodeOutlet></ng-container>',
  host: {
    'class': 'mat-tree cdk-tree app-fixed-tree',
    'role': 'tree',
  },
  // you might want to include the entire styles from tree.scss, but it is not needed in my case
  styles: ['.app-fixed-tree {display:block;}'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.Default,
  providers: [{
    provide: CdkTree,
    useExisting: FixedTreeComponent,
  }],
})
export class FixedTreeComponent<T, K = T> extends MatTree<T, K> {
  renderNodeChanges(data: T[] | ReadonlyArray<T>, dataDiffer?: IterableDiffer<T>, viewContainer: ViewContainerRef = this._nodeOutlet.viewContainer, parentData?: T): void {
    super.renderNodeChanges(data, dataDiffer, viewContainer, parentData);

    // apply new data objects
    if (this.trackBy != null) {
      data.forEach((d, i) => {
        const view: any = viewContainer.get(i);
        if (view && view.context) {
          view.context.$implicit = d;
        }
      });
    }
  }
}

rklfss avatar Jan 28 '21 08:01 rklfss

Any news on this one?

I ran into the same issue and quick and dirty fixed it by using a custom tree component.

I think, we have to get the instance of MatTreeNode/CdkTreeNode and set the data property, but I don't know how to get it. So I just ended up overriding the outlet context.

@Component({
  selector: 'app-fixed-tree',
  template: '<ng-container matTreeNodeOutlet></ng-container>',
  host: {
    'class': 'mat-tree cdk-tree app-fixed-tree',
    'role': 'tree',
  },
  // you might want to include the entire styles from tree.scss, but it is not needed in my case
  styles: ['.app-fixed-tree {display:block;}'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.Default,
  providers: [{
    provide: CdkTree,
    useExisting: FixedTreeComponent,
  }],
})
export class FixedTreeComponent<T, K = T> extends MatTree<T, K> {
  renderNodeChanges(data: T[] | ReadonlyArray<T>, dataDiffer?: IterableDiffer<T>, viewContainer: ViewContainerRef = this._nodeOutlet.viewContainer, parentData?: T): void {
    super.renderNodeChanges(data, dataDiffer, viewContainer, parentData);

    // apply new data objects
    if (this.trackBy != null) {
      data.forEach((d, i) => {
        const view: any = viewContainer.get(i);
        if (view && view.context) {
          view.context.$implicit = d;
        }
      });
    }
  }
}

This still leads to the component not updating, plus type of FixedTreeComponent should take one argument FixedTreeComponent<T>

jquartz avatar Jan 28 '21 22:01 jquartz

This still leads to the component not updating

Ok, really? Maybe it's because I update the expansion model right after updating the source which leads to a new change detection. Then it's just a works-for-me-fix, not worth to mention.

plus type of FixedTreeComponent should take one argument FixedTreeComponent<T>

That's not true. K is the type of the trackBy function result and we have to pass it to the MatTree.

rklfss avatar Jan 29 '21 07:01 rklfss

I think I might be affected by this too. Once I add a trackBy function to the tree when refreshing the data, the toggling breaks

jpduckwo avatar Feb 10 '21 06:02 jpduckwo

same issue for me...

cesco69 avatar Mar 01 '22 11:03 cesco69

Same issue, seems to be an issue with CDK Tree. Can't believe this hasn't been fixed over 2 years.

dirkluijk avatar Jan 23 '23 14:01 dirkluijk

I tried to implement a solution, but so far, it seems that the current implementation has some clear flaws and shortcomings that prevent an easy fix.

This is my analysis so far:

  1. CdkTree does not apply any updates on "identity changes" in the IterableDiffer. I tried fixing this in the CdkTree.renderNodeChanges():
changes.forEachIdentityChange((record) => {
  if (record.currentIndex !== null) {
    console.log('processing', data[record.currentIndex]);
    const viewRef = <EmbeddedViewRef<CdkTreeNodeOutletContext<T>>>viewContainer.get(record.currentIndex);
    viewRef.context.$implicit = record.item;
  }
});

That fixes one issue.

  1. The BaseTreeControl (which NestedTreeControl extends) has a trackBy property that is not set. Manually setting it in its constructor fixes that issue: treeControl = new NestedTreeControl<FoodNode, string>(node => node.children, { trackBy: (node) => node.id });. In my opinion, this should be handled by CdkTree automatically or documented more clearly.
  2. At this point, it works as expected, but only on the first level. Child nodes are still not updated. However, it seems that the current implementation doesn't allow existing child nodes to get data updates. CdkTree relies on some weird static variable CdkTreeNode.mostRecentTreeNode that keeps track of the latest rendered node in order to set the data on it, but that doesn't work with data updates.

dirkluijk avatar Jan 26 '23 09:01 dirkluijk

I shared my fixes in this pull request: https://github.com/angular/components/pull/26508.

Those fixes are only part of the solution, I still rely on a workaround (see PR). Any contributions, ideas or feedback is welcome. I am also considering a custom implementation.

dirkluijk avatar Jan 26 '23 16:01 dirkluijk

The issue seems to be still present in 2024

JonasDev17 avatar May 06 '24 13:05 JonasDev17

@devversion Do you have any plans on fixing this in an upcoming version?

This prevents users from optimizing their trees for performance and a side effect of re-rendering the entire tree is that you cannot use css transitions for e.g. the expansion icon as all icons will be re-rendered and all icons will be animated / rotated again.

JonasDev17 avatar May 06 '24 13:05 JonasDev17

Sorry @JonasDev17, I'm not actively working on Angular Components, but maybe a PR can be created that re-uses some of the logic from NgForOf.

devversion avatar May 06 '24 14:05 devversion