From 1d866636bec8e90ce0f890d839821f72a71677e6 Mon Sep 17 00:00:00 2001 From: Aliullov Vlad <91639107+GoodDayForSurf@users.noreply.github.com> Date: Wed, 14 Jan 2026 19:50:21 +0400 Subject: [PATCH 1/3] Fix memory leak while clicks are handled (T1307313) (#31293) --- packages/devextreme-angular/karma.conf.js | 26 +++++++++- .../tests/src/ui/data-grid.spec.ts | 51 +++++++++++++++++++ .../js/__internal/events/m_click.ts | 15 +++++- .../events/utils/m_event_nodes_disposing.ts | 23 +++++++-- .../events.utils.nodesDisposing.tests.js | 51 +++++++++++++++++++ 5 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 packages/devextreme/testing/tests/DevExpress.ui.events/events.utils.nodesDisposing.tests.js diff --git a/packages/devextreme-angular/karma.conf.js b/packages/devextreme-angular/karma.conf.js index 4a377cab4208..7d7e5e64fd3d 100644 --- a/packages/devextreme-angular/karma.conf.js +++ b/packages/devextreme-angular/karma.conf.js @@ -20,7 +20,20 @@ module.exports = function (config) { autoWatch: true, - browsers: ['ChromeHeadless'], + browsers: ['ChromeHeadlessWithGC'], + + customLaunchers: { + ChromeHeadlessWithGC: { + base: 'ChromeHeadless', + flags: [ + '--enable-features=MeasureMemory', + '--js-flags=--expose-gc', + '--no-sandbox', + '--disable-gpu', + '--enable-precise-memory-info', + ], + }, + }, reporters: [ 'progress', @@ -36,13 +49,22 @@ module.exports = function (config) { junitReporter: { outputFile: 'test-results.xml', }, - + beforeMiddleware: ['customHeaders'], // Karma plugins loaded plugins: [ require('karma-jasmine'), require('karma-chrome-launcher'), require('karma-junit-reporter'), require('karma-webpack'), + { + 'middleware:customHeaders': ['factory', function () { + return function (req, res, next) { + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); + next(); + }; + }], + }, ], webpack: webpackConfig, 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 0a19e0ec18c7..33af29dd7eeb 100644 --- a/packages/devextreme-angular/tests/src/ui/data-grid.spec.ts +++ b/packages/devextreme-angular/tests/src/ui/data-grid.spec.ts @@ -562,3 +562,54 @@ describe('Nested DxDataGrid', () => { }, 1000); }); }); + +describe('DxDataGrid slow tests', () => { + const originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + + beforeAll(() => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000; + + TestBed.configureTestingModule( + { + declarations: [TestContainerComponent], + imports: [DxDataGridModule], + }, + ); + }); + afterAll(() => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + }); + + it('should not memory leak after click if dx-data-grid is on page (T1307313)', async () => { + 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 memoryBefore = await (performance as any).measureUserAgentSpecificMemory(); + + for (let i = 0; i < 100; i++) { + document.body.click(); + fixture.detectChanges(); + } + + globalThis.gc(); + + const memoryAfter = await (performance as any).measureUserAgentSpecificMemory(); + const memoryDiff = Math.round((memoryAfter.bytes - memoryBefore.bytes) / 1024); + + expect(memoryDiff).toBeLessThan(30); + }); +}); 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..2a4f3b2b6981 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 @@ -7,13 +7,28 @@ function nodesByEvent(event) { event.delegateTarget, event.relatedTarget, event.currentTarget, - ].filter((node) => !!node); + ].reduce((res, node) => { + if (!!node && !res.includes(node)) { + res.push(node); + } + + return res; + }, []); } 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 { onceCallback, nodes }; }; -export const unsubscribeNodesDisposing = (event, callback) => { - eventsEngine.off(nodesByEvent(event), removeEvent, callback); +export const unsubscribeNodesDisposing = (event, callback, nodes) => { + eventsEngine.off(nodes || nodesByEvent(event), removeEvent, callback); }; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.events/events.utils.nodesDisposing.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.events/events.utils.nodesDisposing.tests.js new file mode 100644 index 000000000000..84e0709004f9 --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.events/events.utils.nodesDisposing.tests.js @@ -0,0 +1,51 @@ +import eventsEngine from 'common/core/events/core/events_engine'; +import { removeEvent } from 'common/core/events/remove'; +import { + subscribeNodesDisposing, + unsubscribeNodesDisposing, +} from '__internal/events/utils/m_event_nodes_disposing'; + +QUnit.testStart(function() { + const markup = ''; + const fixture = document.getElementById('qunit-fixture'); + if(fixture) { + fixture.innerHTML = markup; + } +}); + +QUnit.module('event nodes disposing'); + +QUnit.test('should clean elementDataMap when using subscribeNodesDisposing and unsubscribeNodesDisposing for click', function(assert) { + const testElement = document.getElementById('test-element'); + + const clickEvent = eventsEngine.Event('click', { + target: testElement, + currentTarget: document, + delegateTarget: document + }); + + const subscriptionData = subscribeNodesDisposing(clickEvent, function() {}); + + const afterSubscribeElementData = eventsEngine.elementDataMap.get(document); + const afterSubscribeHandleObjectsCount = afterSubscribeElementData && afterSubscribeElementData[removeEvent] + ? afterSubscribeElementData[removeEvent].handleObjects.length + : 0; + + unsubscribeNodesDisposing(clickEvent, subscriptionData.callback, subscriptionData.nodes); + + const finalElementData = eventsEngine.elementDataMap.get(document); + const afterUnsubscribeHandleObjectsCount = finalElementData && finalElementData[removeEvent] + ? finalElementData[removeEvent].handleObjects.length + : 0; + + assert.ok( + afterSubscribeHandleObjectsCount <= 1, + `HandleObjects should be added for "${removeEvent}" event after subscribe. HandleObjects count: ${afterSubscribeHandleObjectsCount};` + ); + + assert.equal( + afterUnsubscribeHandleObjectsCount, + 0, + `HandleObjects should be removed for "${removeEvent}" event after unsubscribe. HandleObjects count: ${afterUnsubscribeHandleObjectsCount};` + ); +}); From 50c9e6719d0dc6ef6cca75ebf067c9197af8278f Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Thu, 15 Jan 2026 11:32:52 +0100 Subject: [PATCH 2/3] add NODE_OPTIONS: max-old-space-size=8192 for test job on CI --- .github/workflows/wrapper_tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/wrapper_tests.yml b/.github/workflows/wrapper_tests.yml index 1353f3f71d30..c9ecde01efe4 100644 --- a/.github/workflows/wrapper_tests.yml +++ b/.github/workflows/wrapper_tests.yml @@ -149,6 +149,8 @@ jobs: - name: Test ${{ matrix.framework }} run: pnpx nx test devextreme-${{ matrix.framework }} + env: + NODE_OPTIONS: --max-old-space-size=8192 - name: Pack ${{ matrix.framework }} run: pnpx nx pack devextreme-${{ matrix.framework }} From 9d813b2b0937d750ec50cd511c3c4f7bc9b4c49c Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Thu, 15 Jan 2026 14:05:03 +0100 Subject: [PATCH 3/3] add NODE_OPTIONS: max-old-space-size=8192 for test job on CI --- .github/workflows/wrapper_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/wrapper_tests.yml b/.github/workflows/wrapper_tests.yml index c9ecde01efe4..45a67fe5d3c1 100644 --- a/.github/workflows/wrapper_tests.yml +++ b/.github/workflows/wrapper_tests.yml @@ -148,9 +148,9 @@ jobs: run: pnpx puppeteer browsers install chrome@130.0.6723.69 - name: Test ${{ matrix.framework }} - run: pnpx nx test devextreme-${{ matrix.framework }} env: NODE_OPTIONS: --max-old-space-size=8192 + run: pnpx nx test devextreme-${{ matrix.framework }} - name: Pack ${{ matrix.framework }} run: pnpx nx pack devextreme-${{ matrix.framework }}