Skip to content

Commit ff34dac

Browse files
stasmaxymovjelbourn
authored andcommitted
feat(tree): Add support for trackBy (#11267)
Add trackBy support improve performance
1 parent 72e5196 commit ff34dac

File tree

4 files changed

+299
-4
lines changed

4 files changed

+299
-4
lines changed

src/cdk/tree/nested-node.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export class CdkNestedTreeNode<T> extends CdkTreeNode<T> implements AfterContent
6969
}
7070

7171
ngAfterContentInit() {
72-
this._dataDiffer = this._differs.find([]).create();
72+
this._dataDiffer = this._differs.find([]).create(this._tree.trackBy);
7373
if (!this._tree.treeControl.getChildren) {
7474
throw getTreeControlFunctionsMissingError();
7575
}

src/cdk/tree/tree.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,13 @@ nodes should be sent to tree component based on current expansion status.
166166

167167
The data source for nested tree has an option to leave the node expansion/collapsing event for each
168168
tree node component to handle.
169+
170+
##### `trackBy`
171+
172+
To improve performance, a `trackBy` function can be provided to the tree similar to Angular’s
173+
[`ngFor` `trackBy`](https://angular.io/api/common/NgForOf#change-propagation). This informs the
174+
tree how to uniquely identify nodes to track how the data changes with each update.
175+
176+
```html
177+
<cdk-tree [dataSource]="dataSource" [treeControl]="treeControl" [trackBy]="trackByFn">
178+
```

src/cdk/tree/tree.spec.ts

Lines changed: 277 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88
import {ComponentFixture, TestBed, fakeAsync, flush} from '@angular/core/testing';
9-
import {Component, ViewChild} from '@angular/core';
9+
import {Component, ViewChild, TrackByFunction} from '@angular/core';
1010

1111
import {CollectionViewer, DataSource} from '@angular/cdk/collections';
1212
import {combineLatest, BehaviorSubject, Observable} from 'rxjs';
@@ -296,6 +296,97 @@ describe('CdkTree', () => {
296296
[`[topping_3] - [cheese_3] + [base_3]`]);
297297
});
298298
});
299+
300+
describe('with trackBy', () => {
301+
let fixture: ComponentFixture<CdkTreeAppWithTrackBy>;
302+
let component: CdkTreeAppWithTrackBy;
303+
304+
function createTrackByTestComponent(trackByStrategy: 'reference' | 'property' | 'index') {
305+
configureCdkTreeTestingModule([CdkTreeAppWithTrackBy]);
306+
fixture = TestBed.createComponent(CdkTreeAppWithTrackBy);
307+
component = fixture.componentInstance;
308+
component.trackByStrategy = trackByStrategy;
309+
dataSource = component.dataSource as FakeDataSource;
310+
tree = component.tree;
311+
treeElement = fixture.nativeElement.querySelector('cdk-tree');
312+
313+
fixture.detectChanges();
314+
315+
// Each node receives an attribute 'initialIndex' the element's original place
316+
getNodes(treeElement).forEach((node: Element, index: number) => {
317+
node.setAttribute('initialIndex', index.toString());
318+
});
319+
320+
// Prove that the attributes match their indicies
321+
const initialNodes = getNodes(treeElement);
322+
expect(initialNodes[0].getAttribute('initialIndex')).toBe('0');
323+
expect(initialNodes[1].getAttribute('initialIndex')).toBe('1');
324+
expect(initialNodes[2].getAttribute('initialIndex')).toBe('2');
325+
}
326+
327+
function mutateData() {
328+
// Swap first and second data in data array
329+
const copiedData = component.dataSource.data.slice();
330+
const temp = copiedData[0];
331+
copiedData[0] = copiedData[1];
332+
copiedData[1] = temp;
333+
334+
// Remove the third element
335+
copiedData.splice(2, 1);
336+
337+
// Add new data
338+
component.dataSource.data = copiedData;
339+
component.dataSource.addData();
340+
}
341+
342+
it('should add/remove/move nodes with reference-based trackBy', () => {
343+
createTrackByTestComponent('reference');
344+
mutateData();
345+
346+
// Expect that the first and second nodes were swapped and that the last node is new
347+
const changedNodes = getNodes(treeElement);
348+
expect(changedNodes.length).toBe(3);
349+
expect(changedNodes[0].getAttribute('initialIndex')).toBe('1');
350+
expect(changedNodes[1].getAttribute('initialIndex')).toBe('0');
351+
expect(changedNodes[2].getAttribute('initialIndex')).toBe(null);
352+
});
353+
354+
it('should add/remove/move nodes with property-based trackBy', () => {
355+
createTrackByTestComponent('property');
356+
mutateData();
357+
358+
// Change each item reference to show that the trackby is checking the item properties.
359+
// Otherwise this would cause them all to be removed/added.
360+
component.dataSource.data = component.dataSource.data
361+
.map(item => new TestData(item.pizzaTopping, item.pizzaCheese, item.pizzaBase, ));
362+
363+
// Expect that the first and second nodes were swapped and that the last node is new
364+
const changedNodes = getNodes(treeElement);
365+
expect(changedNodes.length).toBe(3);
366+
expect(changedNodes[0].getAttribute('initialIndex')).toBe('1');
367+
expect(changedNodes[1].getAttribute('initialIndex')).toBe('0');
368+
expect(changedNodes[2].getAttribute('initialIndex')).toBe(null);
369+
});
370+
371+
it('should add/remove/move nodes with index-based trackBy', () => {
372+
createTrackByTestComponent('index');
373+
mutateData();
374+
375+
// Change each item reference to show that the trackby is checking the index.
376+
// Otherwise this would cause them all to be removed/added.
377+
component.dataSource.data = component.dataSource.data
378+
.map(item => new TestData(item.pizzaTopping, item.pizzaCheese, item.pizzaBase, ));
379+
380+
// Expect first two to be the same since they were swapped but indicies are consistent.
381+
// The third element was removed and caught by the tree so it was removed before another
382+
// item was added, so it is without an initial index.
383+
const changedNodes = getNodes(treeElement);
384+
expect(changedNodes.length).toBe(3);
385+
expect(changedNodes[0].getAttribute('initialIndex')).toBe('0');
386+
expect(changedNodes[1].getAttribute('initialIndex')).toBe('1');
387+
expect(changedNodes[2].getAttribute('initialIndex')).toBe(null);
388+
});
389+
});
299390
});
300391

301392
describe('nested tree', () => {
@@ -599,6 +690,128 @@ describe('CdkTree', () => {
599690
});
600691
});
601692

693+
describe('with trackBy', () => {
694+
let fixture: ComponentFixture<NestedCdkTreeAppWithTrackBy>;
695+
let component: NestedCdkTreeAppWithTrackBy;
696+
697+
function createTrackByTestComponent(trackByStrategy: 'reference' | 'property' | 'index') {
698+
configureCdkTreeTestingModule([NestedCdkTreeAppWithTrackBy]);
699+
fixture = TestBed.createComponent(NestedCdkTreeAppWithTrackBy);
700+
component = fixture.componentInstance;
701+
component.trackByStrategy = trackByStrategy;
702+
dataSource = component.dataSource as FakeDataSource;
703+
704+
tree = component.tree;
705+
treeElement = fixture.nativeElement.querySelector('cdk-tree');
706+
707+
fixture.detectChanges();
708+
709+
// Each node receives an attribute 'initialIndex' the element's original place
710+
getNodes(treeElement).forEach((node: Element, index: number) => {
711+
node.setAttribute('initialIndex', index.toString());
712+
});
713+
714+
// Prove that the attributes match their indicies
715+
const initialNodes = getNodes(treeElement);
716+
expect(initialNodes.length).toBe(3);
717+
initialNodes.forEach((node, index) => {
718+
expect(node.getAttribute('initialIndex')).toBe(`${index}`);
719+
});
720+
721+
const parent = dataSource.data[0];
722+
dataSource.addChild(parent, false);
723+
dataSource.addChild(parent, false);
724+
dataSource.addChild(parent, false);
725+
getNodes(initialNodes[0]).forEach((node: Element, index: number) => {
726+
node.setAttribute('initialIndex', `c${index}`);
727+
});
728+
getNodes(initialNodes[0]).forEach((node, index) => {
729+
expect(node.getAttribute('initialIndex')).toBe(`c${index}`);
730+
});
731+
}
732+
733+
function mutateChildren(parent: TestData) {
734+
// Swap first and second data in data array
735+
const copiedData = parent.children.slice();
736+
const temp = copiedData[0];
737+
copiedData[0] = copiedData[1];
738+
copiedData[1] = temp;
739+
740+
// Remove the third element
741+
copiedData.splice(2, 1);
742+
743+
// Add new data
744+
parent.children = copiedData;
745+
parent.observableChildren.next(copiedData);
746+
component.dataSource.addChild(parent, false);
747+
}
748+
749+
it('should add/remove/move children nodes with reference-based trackBy', () => {
750+
createTrackByTestComponent('reference');
751+
mutateChildren(dataSource.data[0]);
752+
753+
const changedNodes = getNodes(treeElement);
754+
expect(changedNodes.length).toBe(6);
755+
expect(changedNodes[0].getAttribute('initialIndex')).toBe('0');
756+
757+
// Expect that the first and second child nodes were swapped and that the last node is new
758+
expect(changedNodes[1].getAttribute('initialIndex')).toBe('c1');
759+
expect(changedNodes[2].getAttribute('initialIndex')).toBe('c0');
760+
expect(changedNodes[3].getAttribute('initialIndex')).toBe(null);
761+
762+
expect(changedNodes[4].getAttribute('initialIndex')).toBe('1');
763+
expect(changedNodes[5].getAttribute('initialIndex')).toBe('2');
764+
});
765+
766+
it('should add/remove/move children nodes with property-based trackBy', () => {
767+
createTrackByTestComponent('property');
768+
mutateChildren(dataSource.data[0]);
769+
770+
// Change each item reference to show that the trackby is checking the item properties.
771+
// Otherwise this would cause them all to be removed/added.
772+
dataSource.data[0].observableChildren.next(dataSource.data[0].children
773+
.map(item => new TestData(item.pizzaTopping, item.pizzaCheese, item.pizzaBase)));
774+
775+
// Expect that the first and second nodes were swapped and that the last node is new
776+
const changedNodes = getNodes(treeElement);
777+
expect(changedNodes.length).toBe(6);
778+
expect(changedNodes[0].getAttribute('initialIndex')).toBe('0');
779+
780+
// Expect that the first and second child nodes were swapped and that the last node is new
781+
expect(changedNodes[1].getAttribute('initialIndex')).toBe('c1');
782+
expect(changedNodes[2].getAttribute('initialIndex')).toBe('c0');
783+
expect(changedNodes[3].getAttribute('initialIndex')).toBe(null);
784+
785+
expect(changedNodes[4].getAttribute('initialIndex')).toBe('1');
786+
expect(changedNodes[5].getAttribute('initialIndex')).toBe('2');
787+
});
788+
789+
it('should add/remove/move children nodes with index-based trackBy', () => {
790+
createTrackByTestComponent('index');
791+
mutateChildren(dataSource.data[0]);
792+
793+
// Change each item reference to show that the trackby is checking the index.
794+
// Otherwise this would cause them all to be removed/added.
795+
dataSource.data[0].observableChildren.next(dataSource.data[0].children
796+
.map(item => new TestData(item.pizzaTopping, item.pizzaCheese, item.pizzaBase)));
797+
798+
const changedNodes = getNodes(treeElement);
799+
expect(changedNodes.length).toBe(6);
800+
expect(changedNodes[0].getAttribute('initialIndex')).toBe('0');
801+
802+
// Expect first two children to be the same since they were swapped
803+
// but indicies are consistent.
804+
// The third element was removed and caught by the tree so it was removed before another
805+
// item was added, so it is without an initial index.
806+
expect(changedNodes[1].getAttribute('initialIndex')).toBe('c0');
807+
expect(changedNodes[2].getAttribute('initialIndex')).toBe('c1');
808+
expect(changedNodes[3].getAttribute('initialIndex')).toBe(null);
809+
810+
expect(changedNodes[4].getAttribute('initialIndex')).toBe('1');
811+
expect(changedNodes[5].getAttribute('initialIndex')).toBe('2');
812+
});
813+
});
814+
602815
it('should throw an error when missing function in nested tree', fakeAsync(() => {
603816
configureCdkTreeTestingModule([NestedCdkErrorTreeApp]);
604817
expect(() => {
@@ -1144,3 +1357,66 @@ class DepthNestedCdkTreeApp {
11441357

11451358
@ViewChild(CdkTree) tree: CdkTree<TestData>;
11461359
}
1360+
1361+
@Component({
1362+
template: `
1363+
<cdk-tree [dataSource]="dataSource" [treeControl]="treeControl" [trackBy]="trackByFn">
1364+
<cdk-tree-node *cdkTreeNodeDef="let node" class="customNodeClass">
1365+
{{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}}
1366+
</cdk-tree-node>
1367+
</cdk-tree>
1368+
`
1369+
})
1370+
class CdkTreeAppWithTrackBy {
1371+
trackByStrategy: 'reference' | 'property' | 'index' = 'reference';
1372+
1373+
trackByFn: TrackByFunction<TestData> = (index, item) => {
1374+
switch (this.trackByStrategy) {
1375+
case 'reference': return item;
1376+
case 'property': return item.pizzaBase;
1377+
case 'index': return index;
1378+
}
1379+
}
1380+
1381+
getLevel = (node: TestData) => node.level;
1382+
isExpandable = (node: TestData) => node.children.length > 0;
1383+
1384+
treeControl: TreeControl<TestData> = new FlatTreeControl(this.getLevel, this.isExpandable);
1385+
dataSource: FakeDataSource = new FakeDataSource(this.treeControl);
1386+
1387+
@ViewChild(CdkTree) tree: CdkTree<TestData>;
1388+
}
1389+
1390+
@Component({
1391+
template: `
1392+
<cdk-tree [dataSource]="dataArray" [treeControl]="treeControl" [trackBy]="trackByFn">
1393+
<cdk-nested-tree-node *cdkTreeNodeDef="let node">
1394+
[{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}]
1395+
<ng-template cdkTreeNodeOutlet></ng-template>
1396+
</cdk-nested-tree-node>
1397+
</cdk-tree>
1398+
`
1399+
})
1400+
class NestedCdkTreeAppWithTrackBy {
1401+
trackByStrategy: 'reference' | 'property' | 'index' = 'reference';
1402+
1403+
trackByFn: TrackByFunction<TestData> = (index, item) => {
1404+
switch (this.trackByStrategy) {
1405+
case 'reference': return item;
1406+
case 'property': return item.pizzaBase;
1407+
case 'index': return index;
1408+
}
1409+
}
1410+
1411+
getChildren = (node: TestData) => node.observableChildren;
1412+
1413+
treeControl: TreeControl<TestData> = new NestedTreeControl(this.getChildren);
1414+
1415+
dataSource: FakeDataSource = new FakeDataSource(this.treeControl);
1416+
1417+
get dataArray() {
1418+
return this.dataSource.data;
1419+
}
1420+
1421+
@ViewChild(CdkTree) tree: CdkTree<TestData>;
1422+
}

src/cdk/tree/tree.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ import {
2424
QueryList,
2525
ViewChild,
2626
ViewContainerRef,
27-
ViewEncapsulation
27+
ViewEncapsulation,
28+
TrackByFunction
2829
} from '@angular/core';
2930
import {BehaviorSubject, Observable, of as observableOf, Subject, Subscription} from 'rxjs';
3031
import {takeUntil} from 'rxjs/operators';
@@ -165,6 +166,14 @@ export class CdkTree<T>
165166
/** The tree controller */
166167
@Input() treeControl: TreeControl<T>;
167168

169+
/**
170+
* Tracking function that will be used to check the differences in data changes. Used similarly
171+
* to `ngFor` `trackBy` function. Optimize node operations by identifying a node based on its data
172+
* relative to the function to know if a node should be added/removed/moved.
173+
* Accepts a function that takes two parameters, `index` and `item`.
174+
*/
175+
@Input() trackBy: TrackByFunction<T>;
176+
168177
// Outlets within the tree's template where the dataNodes will be inserted.
169178
@ViewChild(CdkTreeNodeOutlet) _nodeOutlet: CdkTreeNodeOutlet;
170179

@@ -184,7 +193,7 @@ export class CdkTree<T>
184193
private _changeDetectorRef: ChangeDetectorRef) {}
185194

186195
ngOnInit() {
187-
this._dataDiffer = this._differs.find([]).create();
196+
this._dataDiffer = this._differs.find([]).create(this.trackBy);
188197
if (!this.treeControl) {
189198
throw getTreeControlMissingError();
190199
}

0 commit comments

Comments
 (0)