Skip to content

Commit 83b72d5

Browse files
committed
fix(grid): fix leaks in grid and improve leaks reporting
1 parent 8ffb8b9 commit 83b72d5

File tree

3 files changed

+84
-23
lines changed

3 files changed

+84
-23
lines changed

projects/igniteui-angular/src/lib/grids/grid-base.directive.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
AfterViewInit,
55
booleanAttribute,
66
ChangeDetectorRef,
7+
ComponentRef,
78
ContentChild,
89
ContentChildren,
910
createComponent,
@@ -3121,6 +3122,7 @@ export abstract class IgxGridBaseDirective implements GridType,
31213122
protected _sortingOptions: ISortingOptions = { mode: 'multiple' };
31223123
protected _filterStrategy: IFilteringStrategy = new FilteringStrategy();
31233124
protected _autoGeneratedCols = [];
3125+
protected _autoGeneratedColsRefs: ComponentRef<IgxColumnComponent>[] = [];
31243126
protected _dataView = [];
31253127
protected _lastSearchInfo: ISearchInfo = {
31263128
searchText: '',
@@ -3920,15 +3922,19 @@ export abstract class IgxGridBaseDirective implements GridType,
39203922
/** @hidden @internal */
39213923
public setUpPaginator() {
39223924
if (this.paginator) {
3923-
this.paginator.pageChange.pipe(takeWhile(() => !!this.paginator), filter(() => !this._init))
3925+
this.paginator.pageChange
3926+
.pipe(takeWhile(() => !!this.paginator), filter(() => !this._init))
3927+
.pipe(takeUntil(this.destroy$))
39243928
.subscribe(() => {
39253929
this.selectionService.clear(true);
39263930
this.crudService.endEdit(false);
39273931
this.pipeTrigger++;
39283932
this.navigateTo(0);
39293933
this.notifyChanges();
39303934
});
3931-
this.paginator.perPageChange.pipe(takeWhile(() => !!this.paginator), filter(() => !this._init))
3935+
this.paginator.perPageChange
3936+
.pipe(takeWhile(() => !!this.paginator), filter(() => !this._init))
3937+
.pipe(takeUntil(this.destroy$))
39323938
.subscribe(() => {
39333939
this.selectionService.clear(true);
39343940
this.page = 0;
@@ -3996,8 +4002,10 @@ export abstract class IgxGridBaseDirective implements GridType,
39964002
*/
39974003
public _zoneBegoneListeners() {
39984004
this.zone.runOutsideAngular(() => {
3999-
this.verticalScrollContainer.getScroll().addEventListener('scroll', this.verticalScrollHandler.bind(this));
4000-
this.headerContainer?.getScroll().addEventListener('scroll', this.horizontalScrollHandler.bind(this));
4005+
this.verticalScrollHandler = this.verticalScrollHandler.bind(this);
4006+
this.horizontalScrollHandler = this.horizontalScrollHandler.bind(this);
4007+
this.verticalScrollContainer.getScroll().addEventListener('scroll', this.verticalScrollHandler);
4008+
this.headerContainer?.getScroll().addEventListener('scroll', this.horizontalScrollHandler);
40014009
if (this.hasColumnsToAutosize) {
40024010
this.headerContainer?.dataChanged.pipe(takeUntil(this.destroy$)).subscribe(() => {
40034011
this.cdr.detectChanges();
@@ -4025,7 +4033,7 @@ export abstract class IgxGridBaseDirective implements GridType,
40254033
this._zoneBegoneListeners();
40264034

40274035
const vertScrDC = this.verticalScrollContainer.displayContainer;
4028-
vertScrDC.addEventListener('scroll', this.preventContainerScroll.bind(this));
4036+
vertScrDC.addEventListener('scroll', this.preventContainerScroll);
40294037

40304038
this._pinnedRowList.changes
40314039
.pipe(takeUntil(this.destroy$))
@@ -4100,6 +4108,8 @@ export abstract class IgxGridBaseDirective implements GridType,
41004108
this.tmpOutlets.forEach((tmplOutlet) => {
41014109
tmplOutlet.cleanCache();
41024110
});
4111+
this._autoGeneratedColsRefs.forEach(ref => ref.destroy());
4112+
this._autoGeneratedColsRefs = [];
41034113

41044114
this.destroy$.next(true);
41054115
this.destroy$.complete();
@@ -6106,7 +6116,7 @@ export abstract class IgxGridBaseDirective implements GridType,
61066116
this.configureRowEditingOverlay(id, this.rowList.length <= MIN_ROW_EDITING_COUNT_THRESHOLD);
61076117

61086118
this.rowEditingOverlay.open(this.rowEditSettings);
6109-
this.rowEditingOverlay.element.addEventListener('wheel', this.rowEditingWheelHandler.bind(this));
6119+
this.rowEditingOverlay.element.addEventListener('wheel', this.rowEditingWheelHandler);
61106120
}
61116121

61126122
/**
@@ -6238,7 +6248,7 @@ export abstract class IgxGridBaseDirective implements GridType,
62386248
/**
62396249
* @hidden
62406250
*/
6241-
public rowEditingWheelHandler(event: WheelEvent) {
6251+
public rowEditingWheelHandler = (event: WheelEvent) => {
62426252
if (event.deltaY > 0) {
62436253
this.verticalScrollContainer.scrollNext();
62446254
} else {
@@ -7089,11 +7099,14 @@ export abstract class IgxGridBaseDirective implements GridType,
70897099
const fields = this.generateDataFields(data);
70907100
const columns = [];
70917101

7102+
this._autoGeneratedColsRefs.forEach(ref => ref.destroy());
7103+
this._autoGeneratedColsRefs = [];
70927104
fields.forEach((field) => {
70937105
const ref = createComponent(IgxColumnComponent, { environmentInjector: this.envInjector, elementInjector: this.injector });
70947106
ref.instance.field = field;
70957107
ref.instance.dataType = this.resolveDataTypes(data[0][field]);
70967108
ref.changeDetectorRef.detectChanges();
7109+
this._autoGeneratedColsRefs.push(ref);
70977110
columns.push(ref.instance);
70987111
});
70997112
this._autoGeneratedCols = columns;
@@ -7401,7 +7414,7 @@ export abstract class IgxGridBaseDirective implements GridType,
74017414
}
74027415

74037416
if (delayScrolling) {
7404-
this.verticalScrollContainer.dataChanged.pipe(first()).subscribe(() => {
7417+
this.verticalScrollContainer.dataChanged.pipe(first(), takeUntil(this.destroy$)).subscribe(() => {
74057418
this.scrollDirective(this.verticalScrollContainer,
74067419
typeof (row) === 'number' ? row : this.unpinnedDataView.indexOf(row));
74077420
});
@@ -7555,7 +7568,7 @@ export abstract class IgxGridBaseDirective implements GridType,
75557568
let row = this.summariesRowList.filter(s => s.index !== 0).concat(this.rowList.toArray()).find(r => r.index === rowIndex);
75567569
if (!row) {
75577570
if ((this as any).totalItemCount) {
7558-
this.verticalScrollContainer.dataChanged.pipe(first()).subscribe(() => {
7571+
this.verticalScrollContainer.dataChanged.pipe(first(), takeUntil(this.destroy$)).subscribe(() => {
75597572
this.cdr.detectChanges();
75607573
row = this.summariesRowList.filter(s => s.index !== 0).concat(this.rowList.toArray()).find(r => r.index === rowIndex);
75617574
const cbArgs = this.getNavigationArguments(row, visibleColIndex);

projects/igniteui-angular/src/lib/grids/grid/grid.component.spec.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { SampleTestData } from '../../test-utils/sample-test-data.spec';
1515
import { BasicGridComponent } from '../../test-utils/grid-base-components.spec';
1616
import { UIInteractions, wait } from '../../test-utils/ui-interactions.spec';
1717
import { IgxStringFilteringOperand, IgxNumberFilteringOperand } from '../../data-operations/filtering-condition';
18-
import { configureTestSuite } from '../../test-utils/configure-suite';
18+
import { configureTestSuite, skipLeakCheck } from '../../test-utils/configure-suite';
1919
import { GridSelectionMode, Size } from '../common/enums';
2020
import { FilteringExpressionsTree } from '../../data-operations/filtering-expressions-tree';
2121
import { FilteringLogic } from '../../data-operations/filtering-expression.interface';
@@ -37,7 +37,7 @@ describe('IgxGrid Component Tests #grid', () => {
3737
const TBODY_CLASS = '.igx-grid__tbody-content';
3838
const THEAD_CLASS = '.igx-grid-thead';
3939

40-
configureTestSuite();
40+
configureTestSuite({ checkLeaks: true });
4141

4242
describe('IgxGrid - input properties', () => {
4343
beforeAll(waitForAsync(() => {
@@ -1502,7 +1502,7 @@ describe('IgxGrid Component Tests #grid', () => {
15021502
}));
15031503

15041504
it('should render correct columns if after scrolling right container size changes so that all columns become visible.',
1505-
async () => {
1505+
skipLeakCheck(async () => {
15061506
const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent);
15071507
fix.detectChanges();
15081508
const grid = fix.componentInstance.grid;
@@ -1523,8 +1523,9 @@ describe('IgxGrid Component Tests #grid', () => {
15231523
expect(headers.length).toEqual(5);
15241524
for (let i = 0; i < headers.length; i++) {
15251525
expect(headers[i].context.column.field).toEqual(grid.columnList.get(i).field);
1526+
// Note: We use skipLeakCheck because using `headers[i].context` messes up memory leak detection
15261527
}
1527-
});
1528+
}));
15281529

15291530
it('Should render date and number values based on default formatting', fakeAsync(() => {
15301531
const fixture = TestBed.createComponent(IgxGridFormattingComponent);
@@ -2715,6 +2716,7 @@ describe('IgxGrid Component Tests #grid', () => {
27152716

27162717
afterEach(() => {
27172718
observer?.disconnect();
2719+
observer = null;
27182720
});
27192721

27202722
it('should render the grid in a certain amount of time', async () => {

projects/igniteui-angular/src/lib/test-utils/configure-suite.ts

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NgModuleRef } from '@angular/core';
22
import { TestBed, getTestBed, ComponentFixture, waitForAsync } from '@angular/core/testing';
33

4-
const checkLeaks = 'gc' in window;
4+
const checkLeaksAvailable = typeof window.gc === 'function';
55

66
const debug = false;
77
function debugLog(...args) {
@@ -10,11 +10,16 @@ function debugLog(...args) {
1010
}
1111
}
1212

13+
let _skipLeakCheck = false;
14+
const throwOnLeak = true;
15+
1316
interface ConfigureOptions {
1417
/**
1518
* Check for memory leaks when the tests finishes.
1619
* Note, this only works in Chrome configurations with expose the gc.
17-
* Caveats: if there are pending (non-cancelled) timers or animation frames it may report false positives.
20+
* Caveats:
21+
* * if there are pending (non-cancelled) timers or animation frames it may report false positives.
22+
* * if there's a beforeEach create it must be cleaned up in an afterEach to avoid being detected as a leak
1823
*/
1924
checkLeaks?: boolean;
2025
}
@@ -27,11 +32,13 @@ interface ConfigureOptions {
2732
*/
2833

2934
export const configureTestSuite = (configureActionOrOptions?: (() => TestBed) | ConfigureOptions, options: ConfigureOptions = {}) => {
35+
setupJasmineCurrentTest();
36+
3037
const configureAction = typeof configureActionOrOptions === 'function' ? configureActionOrOptions : undefined;
3138
options = (configureActionOrOptions && typeof configureActionOrOptions === 'object') ? configureActionOrOptions : options;
32-
options.checkLeaks = options.checkLeaks && checkLeaks;
39+
options.checkLeaks = options.checkLeaks && checkLeaksAvailable;
3340

34-
let componentRefs: WeakRef<{}>[];
41+
let componentRefs: { test: string, ref: WeakRef<{}> }[];
3542
const moduleRefs = new Set<NgModuleRef<any>>();
3643

3744
const testBed = getTestBed();
@@ -57,7 +64,9 @@ export const configureTestSuite = (configureActionOrOptions?: (() => TestBed) |
5764
componentRefs = [];
5865
testBed.createComponent = function () {
5966
const fixture = originCreateComponent.apply(testBed, arguments);
60-
componentRefs.push(new WeakRef(fixture.componentInstance));
67+
if (!_skipLeakCheck) {
68+
componentRefs.push({ test: jasmine['currentTest'].fullName, ref: new WeakRef(fixture.componentInstance) });
69+
}
6170
return fixture;
6271
};
6372
}
@@ -73,13 +82,19 @@ export const configureTestSuite = (configureActionOrOptions?: (() => TestBed) |
7382

7483
function reportLeaks() {
7584
gc();
76-
const leaks = componentRefs.map(ref => ref.deref()).filter(i => !!i);
85+
const leaks = componentRefs.map(({test, ref}) => ({ test, instance: ref.deref()})).filter(item => !!item.instance);
7786
if (leaks.length > 0) {
7887
console.warn(`Detected ${leaks.length} leaks:`);
79-
const classNames = [...new Set(leaks.map(i => i.constructor.name))];
80-
for (const name of classNames) {
81-
const count = leaks.filter(i => i.constructor.name === name).length;
82-
console.warn(` · ${name}: ${count}`);
88+
for (const test of new Set(leaks.map(l => l.test))) {
89+
const testLeaks = leaks.filter(l => l.test === test).map(l => l.instance);
90+
const classNames = new Set(testLeaks.map(i => i.constructor.name));
91+
for (const name of classNames) {
92+
const count = testLeaks.filter(i => i.constructor.name === name).length;
93+
console.warn(` · ${name}: ${count} - ${test}`);
94+
}
95+
}
96+
if (throwOnLeak) {
97+
throw new Error(`Detected ${leaks.length} leaks`);
8398
}
8499
} else {
85100
debugLog('No leaks detected');
@@ -124,6 +139,37 @@ export const configureTestSuite = (configureActionOrOptions?: (() => TestBed) |
124139
});
125140
};
126141

142+
/** Calls to Testbed.createComponent() inside this wrapper wont be tracked for leaks */
143+
export function skipLeakCheck(fn: () => void | Promise<unknown>) {
144+
return function() {
145+
_skipLeakCheck = true;
146+
const res = fn();
147+
if (res instanceof Promise) {
148+
return res.finally(() => {
149+
_skipLeakCheck = false;
150+
});
151+
} else {
152+
_skipLeakCheck = false;
153+
return res;
154+
}
155+
}
156+
}
157+
158+
let setupJasmineCurrentTestDone = false;
159+
function setupJasmineCurrentTest() {
160+
if (!setupJasmineCurrentTestDone) {
161+
jasmine.getEnv().addReporter({
162+
specStarted(result) {
163+
jasmine['currentTest'] = result;
164+
},
165+
specDone(result) {
166+
jasmine['currentTest'] = result;
167+
},
168+
});
169+
setupJasmineCurrentTestDone = true;
170+
}
171+
}
172+
127173
// TODO: enable on re-run by selecting enable debug logging
128174
// https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/troubleshooting-workflows/enabling-debug-logging
129175
const shardLogging = false;

0 commit comments

Comments
 (0)