diff --git a/packages/devextreme-angular/karma.conf.js b/packages/devextreme-angular/karma.conf.js
index 08a15e7c9e34..de45ea4b51fd 100644
--- a/packages/devextreme-angular/karma.conf.js
+++ b/packages/devextreme-angular/karma.conf.js
@@ -18,7 +18,19 @@ module.exports = function (config) {
autoWatch: true,
- browsers: ['ChromeHeadless'],
+ browsers: ['ChromeHeadlessWithGC'],
+
+ customLaunchers: {
+ ChromeHeadlessWithGC: {
+ base: 'ChromeHeadless',
+ flags: [
+ '--js-flags=--expose-gc',
+ '--no-sandbox',
+ '--disable-gpu',
+ '--enable-precise-memory-info',
+ ],
+ },
+ },
reporters: [
'progress',
diff --git a/packages/devextreme-angular/tests/src/ui/data-grid.spec.ts b/packages/devextreme-angular/tests/src/ui/data-grid.spec.ts
index 0d167a1e0663..25b911dc3f2c 100644
--- a/packages/devextreme-angular/tests/src/ui/data-grid.spec.ts
+++ b/packages/devextreme-angular/tests/src/ui/data-grid.spec.ts
@@ -558,4 +558,36 @@ describe('Nested DxDataGrid', () => {
}, 1000);
}, 1000);
});
+
+ it('should not memory leak after click if dx-data-grid is on page (T1307313)', () => {
+ TestBed.overrideComponent(TestContainerComponent, {
+ set: {
+ template: '',
+ },
+ });
+
+ const fixture = TestBed.createComponent(TestContainerComponent);
+ fixture.detectChanges();
+
+ for (let i = 0; i < 100; i++) {
+ document.body.click();
+ }
+
+ fixture.detectChanges();
+ globalThis.gc();
+
+ const { memory } = performance as any;
+ const jsHeapSizeBefore = memory.usedJSHeapSize;
+
+ for (let i = 0; i < 100; i++) {
+ document.body.click();
+ }
+
+ fixture.detectChanges();
+ globalThis.gc();
+
+ const memoryDiff = memory.usedJSHeapSize - jsHeapSizeBefore;
+
+ expect(memoryDiff <= 0).toBeTruthy();
+ });
});
diff --git a/packages/devextreme/js/__internal/events/m_click.ts b/packages/devextreme/js/__internal/events/m_click.ts
index 03bc4dc7d685..dfea4f52c0a0 100644
--- a/packages/devextreme/js/__internal/events/m_click.ts
+++ b/packages/devextreme/js/__internal/events/m_click.ts
@@ -17,6 +17,7 @@ const misc = { requestAnimationFrame, cancelAnimationFrame };
let prevented: boolean | null = null;
let lastFiredEvent = null;
+const subscriptions = new Map();
const onNodeRemove = () => {
lastFiredEvent = null;
@@ -32,9 +33,19 @@ const clickHandler = function (e) {
originalEvent.DXCLICK_FIRED = true;
}
- unsubscribeNodesDisposing(lastFiredEvent, onNodeRemove);
+ if (subscriptions.has(lastFiredEvent)) {
+ const { nodes, callback } = subscriptions.get(lastFiredEvent);
+
+ unsubscribeNodesDisposing(lastFiredEvent, callback, nodes);
+
+ subscriptions.delete(lastFiredEvent);
+ }
+
lastFiredEvent = originalEvent;
- subscribeNodesDisposing(lastFiredEvent, onNodeRemove);
+
+ const subscriptionData = subscribeNodesDisposing(lastFiredEvent, onNodeRemove);
+
+ subscriptions.set(lastFiredEvent, subscriptionData);
fireEvent({
type: CLICK_EVENT_NAME,
diff --git a/packages/devextreme/js/__internal/events/utils/m_event_nodes_disposing.ts b/packages/devextreme/js/__internal/events/utils/m_event_nodes_disposing.ts
index 4db01c1452c5..87c6f8b8e5d4 100644
--- a/packages/devextreme/js/__internal/events/utils/m_event_nodes_disposing.ts
+++ b/packages/devextreme/js/__internal/events/utils/m_event_nodes_disposing.ts
@@ -11,9 +11,17 @@ function nodesByEvent(event) {
}
export const subscribeNodesDisposing = (event, callback) => {
- eventsEngine.one(nodesByEvent(event), removeEvent, callback);
+ const nodes = nodesByEvent(event);
+ const onceCallback = function (...args) {
+ eventsEngine.off(nodes, removeEvent, onceCallback);
+ return callback(...args);
+ };
+
+ eventsEngine.on(nodes, removeEvent, onceCallback);
+
+ return { callback: onceCallback, nodes };
};
-export const unsubscribeNodesDisposing = (event, callback) => {
- eventsEngine.off(nodesByEvent(event), removeEvent, callback);
+export const unsubscribeNodesDisposing = (event, callback, nodes) => {
+ eventsEngine.off(/* nodes || */ nodesByEvent(event) || nodes, removeEvent, callback);
};
diff --git a/packages/devextreme/testing/launch b/packages/devextreme/testing/launch
index 04e9421f6c32..08bcae36d543 100755
--- a/packages/devextreme/testing/launch
+++ b/packages/devextreme/testing/launch
@@ -41,7 +41,11 @@ function waitForRunner() {
function openBrowser() {
spawn(
getBrowserCommand(),
- [ 'http://localhost:' + PORT ],
+ [ '""',
+ '--js-flags=--expose-gc',
+ '--enable-precise-memory-info',
+ 'http://localhost:' + PORT,
+ ],
{ shell: true, detached: true }
);
}
@@ -49,13 +53,13 @@ function openBrowser() {
function getBrowserCommand() {
switch(platform()) {
case 'win32':
- return 'start';
+ return 'start chrome';
case 'darwin':
return 'open';
case 'linux':
- return 'xdg-open';
+ return 'google-chrome';
}
throw 'Not implemented';
diff --git a/packages/devextreme/testing/tests/DevExpress.ui.events/event_nodes_disposing.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.events/event_nodes_disposing.tests.js
new file mode 100644
index 000000000000..7e0e70c2798d
--- /dev/null
+++ b/packages/devextreme/testing/tests/DevExpress.ui.events/event_nodes_disposing.tests.js
@@ -0,0 +1,61 @@
+import eventsEngine from 'common/core/events/core/events_engine';
+import domAdapter from '__internal/core/m_dom_adapter';
+import clickEventName from 'common/core/events/click';
+
+QUnit.testStart(function() {
+ const markup = '';
+ const fixture = document.getElementById('qunit-fixture');
+ if(fixture) {
+ fixture.innerHTML = markup;
+ }
+});
+
+QUnit.module('event nodes disposing', {
+ afterEach: function() {
+ const document = domAdapter.getDocument();
+ eventsEngine.off(document, clickEventName);
+ }
+});
+
+QUnit.test('should not leak memory when subscribing to dxclick on document and clicking elements', function(assert) {
+ const done = assert.async();
+ const document = domAdapter.getDocument();
+ const testElement = document.getElementById('test-element');
+ let count = 0;
+
+ eventsEngine.on(document, clickEventName, function() {
+ count++;
+ });
+
+ if(typeof globalThis !== 'undefined' && typeof globalThis.gc === 'function') {
+ globalThis.gc();
+ }
+
+ const initialMemory = performance.memory.usedJSHeapSize;
+
+ let i = 0;
+
+ const interval = setInterval(() => {
+ testElement.click();
+ i++;
+
+ if(i > 99) {
+ setTimeout(() => {
+ if(typeof globalThis !== 'undefined' && typeof globalThis.gc === 'function') {
+ globalThis.gc();
+ }
+
+ const finalMemory = performance.memory.usedJSHeapSize;
+ const memoryDiff = finalMemory - initialMemory;
+
+ assert.ok(
+ finalMemory <= 0,
+ `Memory should not leak. Memory before: ${initialMemory}B, Memory after: ${finalMemory}B, Memory diff: ${memoryDiff}B ${count}`
+ );
+ done();
+ }, 50);
+
+ clearInterval(interval);
+ }
+ }, 50);
+});