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); +});