diff --git a/packages/devextreme-angular/tests/src/ui/memory-leak.spec.ts b/packages/devextreme-angular/tests/src/ui/memory-leak.spec.ts new file mode 100644 index 000000000000..473df52b676b --- /dev/null +++ b/packages/devextreme-angular/tests/src/ui/memory-leak.spec.ts @@ -0,0 +1,104 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { DxButtonModule, DxSliderModule } from 'devextreme-angular'; + +@Component({ + standalone: false, + selector: 'test-container-component', + template: '', +}) +class TestContainerComponent { + isVisible = true; +} + +async function forceGC(times = 3): Promise { + for (let i = 0; i < times; i++) { + Array.from({ length: 10_000 }, () => ({ data: new Array(100) })); + globalThis.gc?.(); + } + + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +describe('Memory leak tests', () => { + const originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + + beforeAll(() => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000; + + TestBed.configureTestingModule({ + declarations: [TestContainerComponent], + imports: [DxButtonModule, DxSliderModule], + }); + }); + + afterAll(() => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + }); + + it('should not memory leak after change @if block with DxButton (T1324584)', async () => { + TestBed.overrideComponent(TestContainerComponent, { + set: { + template: '@if (isVisible) {}', + }, + }); + + const fixture = TestBed.createComponent(TestContainerComponent); + const component: TestContainerComponent = fixture.componentInstance; + + fixture.detectChanges(); + + for (let i = 0; i < 100; i++) { + component.isVisible = !component.isVisible; + fixture.detectChanges(); + } + + const memoryBefore = await (performance as any).measureUserAgentSpecificMemory(); + + for (let i = 0; i < 100; i++) { + component.isVisible = !component.isVisible; + fixture.detectChanges(); + } + + await forceGC(); + + const memoryAfter = await (performance as any).measureUserAgentSpecificMemory(); + const memoryDiff = Math.round((memoryAfter.bytes - memoryBefore.bytes) / 1024); + + expect(memoryDiff).toBeLessThan(150); + }); + + it('should not memory leak after change @if block with DxSlider (T1324584)', async () => { + TestBed.overrideComponent(TestContainerComponent, { + set: { + template: '@if (isVisible) {}', + }, + }); + + const fixture = TestBed.createComponent(TestContainerComponent); + const component: TestContainerComponent = fixture.componentInstance; + + fixture.detectChanges(); + + for (let i = 0; i < 100; i++) { + component.isVisible = !component.isVisible; + fixture.detectChanges(); + } + + const memoryBefore = await (performance as any).measureUserAgentSpecificMemory(); + + for (let i = 0; i < 100; i++) { + component.isVisible = !component.isVisible; + fixture.detectChanges(); + } + + await forceGC(); + + const memoryAfter = await (performance as any).measureUserAgentSpecificMemory(); + const memoryDiff = Math.round((memoryAfter.bytes - memoryBefore.bytes) / 1024); + + expect(memoryDiff).toBeLessThan(200); + }); +}); diff --git a/packages/devextreme/js/__internal/core/m_inferno_renderer.ts b/packages/devextreme/js/__internal/core/m_inferno_renderer.ts index 06ee5e06927b..18fcd5003e6f 100644 --- a/packages/devextreme/js/__internal/core/m_inferno_renderer.ts +++ b/packages/devextreme/js/__internal/core/m_inferno_renderer.ts @@ -1,4 +1,5 @@ /* eslint-disable spellcheck/spell-checker */ +import { keyboard } from '@js/common/core/events/short'; import domAdapter from '@js/core/dom_adapter'; import { cleanDataRecursive } from '@js/core/element_data'; import injector from '@js/core/utils/dependency_injector'; @@ -7,6 +8,8 @@ import { render } from 'inferno'; import { createElement } from 'inferno-create-element'; const remove = (element) => { + keyboard.disposeProcessorsForSubtree(element); + const { parentNode } = element; if (parentNode) { diff --git a/packages/devextreme/js/__internal/events/m_short.ts b/packages/devextreme/js/__internal/events/m_short.ts index b65e4fd4d2c4..7a8f278b4419 100644 --- a/packages/devextreme/js/__internal/events/m_short.ts +++ b/packages/devextreme/js/__internal/events/m_short.ts @@ -131,6 +131,57 @@ export const keyboard = { } }, + disposeProcessorsForSubtree(root: Element): void { + if (!root?.nodeType) { + return; + } + + const toElements = (value: unknown): Element[] => { + if (!value) { + return []; + } + + if (value instanceof Element) { + return [value]; + } + + if (Array.isArray(value)) { + return value.filter((item): item is Element => item instanceof Element); + } + + const v = value as { toArray?: () => unknown[]; 0?: unknown }; + + if (typeof v.toArray === 'function') { + const arr = v.toArray(); + + if (Array.isArray(arr)) { + return arr.filter((item): item is Element => item instanceof Element); + } + } + + const first = v[0]; + + return first instanceof Element ? [first] : []; + }; + + const touchesRoot = (value: unknown): boolean => { + const elements = toElements(value); + return elements.some((el) => el === root || root.contains(el)); + }; + + Object.keys(keyboardProcessors).forEach((id) => { + const keyboardProcessor = keyboardProcessors[id]; + + if (!keyboardProcessor) { + return; + } + + if (touchesRoot(keyboardProcessor._element) || touchesRoot(keyboardProcessor._focusTarget)) { + keyboard.off(id); + } + }); + }, + // NOTE: For tests _getProcessor: (listenerId) => keyboardProcessors[listenerId], }; diff --git a/packages/devextreme/js/__internal/ui/slider/m_slider_handle.ts b/packages/devextreme/js/__internal/ui/slider/m_slider_handle.ts index 7af6b44fab85..622e26342f24 100644 --- a/packages/devextreme/js/__internal/ui/slider/m_slider_handle.ts +++ b/packages/devextreme/js/__internal/ui/slider/m_slider_handle.ts @@ -74,6 +74,7 @@ class SliderHandle extends Widget { _clean(): void { super._clean(); + this._sliderTooltip?.dispose(); this._sliderTooltip = null; }