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 }}