From 320291b175bd45f92f39e585acef0913c2ea2e84 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:44:55 +0400 Subject: [PATCH 1/4] fix(grids): prevent CardView template duplicates and leaks (T1336765) --- .../react19/src/utils/componentFinder.js | 5 ++ .../react19/index.jsx | 42 +++++++++++++++ .../tests/card-view-template-rerender.spec.ts | 20 ++++++++ .../core/__tests__/template-manager.test.tsx | 34 +++++++++++++ .../src/core/template-manager.tsx | 2 + .../__snapshots__/options.test.ts.snap | 20 +++++--- .../grids/new/card_view/widget.test.ts | 51 +++++++++++++++++++ .../inferno_wrappers/template_wrapper.tsx | 11 +++- 8 files changed, 175 insertions(+), 10 deletions(-) create mode 100644 e2e/wrappers/examples/card-view-template-rerender/react19/index.jsx create mode 100644 e2e/wrappers/tests/card-view-template-rerender.spec.ts diff --git a/e2e/wrappers/builders/react19/src/utils/componentFinder.js b/e2e/wrappers/builders/react19/src/utils/componentFinder.js index a5a3b48bd2b7..20ba8e84a497 100644 --- a/e2e/wrappers/builders/react19/src/utils/componentFinder.js +++ b/e2e/wrappers/builders/react19/src/utils/componentFinder.js @@ -28,6 +28,11 @@ const COMPONENTS = [ path: 'gantt-template-state-update', name: 'GanttTemplateStateUpdate', component: () => import('@examples/gantt-template-state-update/react19/index.jsx') + }, + { + path: 'card-view-template-rerender', + name: 'CardViewTemplateRerender', + component: () => import('@examples/card-view-template-rerender/react19/index.jsx') } ]; diff --git a/e2e/wrappers/examples/card-view-template-rerender/react19/index.jsx b/e2e/wrappers/examples/card-view-template-rerender/react19/index.jsx new file mode 100644 index 000000000000..67f9f9f5334a --- /dev/null +++ b/e2e/wrappers/examples/card-view-template-rerender/react19/index.jsx @@ -0,0 +1,42 @@ +import React, { useState } from "react"; +import CardView, { Column } from "devextreme-react/card-view"; + +const initialData = [ + { id: 1, updateCount: 0 }, + { id: 2, updateCount: 0 }, +]; + +const renderCard = ({ card }) => ( +
+ {`${card.data.id}: ${card.data.updateCount}`} +
+); + +function App() { + const [items, setItems] = useState(initialData); + + const updateItems = () => { + setItems((prev) => prev.map((item) => ({ + ...item, + updateCount: item.updateCount + 1, + }))); + }; + + return ( +
+ + + + + + +
+ ); +} + +export default App; diff --git a/e2e/wrappers/tests/card-view-template-rerender.spec.ts b/e2e/wrappers/tests/card-view-template-rerender.spec.ts new file mode 100644 index 000000000000..d572bd15aad3 --- /dev/null +++ b/e2e/wrappers/tests/card-view-template-rerender.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from '../fixtures'; + +test.describe('CardView template re-rendering', () => { + test.skip(({ framework }) => framework !== 'react19', 'The example is implemented for React only'); + + test.beforeEach(async ({ page }) => { + await page.goto('/examples/card-view-template-rerender'); + }); + + test('CardView should not duplicate card templates after a dataSource update (T1336765)', async ({ page }) => { + const updateButton = page.locator('button', { hasText: 'Update Data' }); + const cards = page.locator('.custom-card'); + + await expect(cards).toHaveText(['1: 0', '2: 0']); + + await updateButton.click(); + + await expect(cards).toHaveText(['1: 1', '2: 1']); + }); +}); diff --git a/packages/devextreme-react/src/core/__tests__/template-manager.test.tsx b/packages/devextreme-react/src/core/__tests__/template-manager.test.tsx index 61f9528b11c5..3a70eb8e37d7 100644 --- a/packages/devextreme-react/src/core/__tests__/template-manager.test.tsx +++ b/packages/devextreme-react/src/core/__tests__/template-manager.test.tsx @@ -601,4 +601,38 @@ describe('Template Manager', () => { expect(document.querySelector('.render-template-container')?.innerHTML) .toBe('
Render template text-1
'); }); + + it('template is not rendered if the container was removed before the template manager re-rendered (T1336765)', () => { + let createDXTemplates; + + const init = ({ createDXTemplates: createDXTemplatesFn }: InitArgument) => { + createDXTemplates = createDXTemplatesFn; + }; + + const templateOptions = getTemplateOptions([{ type: 'render' }]); + + render( + +
+ undefined} /> +
+ ); + + let dxTemplates; + + act(() => { dxTemplates = createDXTemplates(templateOptions); }); + + const container = document.querySelector('.render-template-container')!; + + act(() => { + dxTemplates.renderKey.render({ + model: { text: 'Render template text' }, + index: 1, + container, + }); + events.triggerHandler(container, 'dxremove'); + }); + + expect(container.innerHTML).toBe(''); + }); }); \ No newline at end of file diff --git a/packages/devextreme-react/src/core/template-manager.tsx b/packages/devextreme-react/src/core/template-manager.tsx index 82c7b6a90a85..d1cf625d4858 100644 --- a/packages/devextreme-react/src/core/template-manager.tsx +++ b/packages/devextreme-react/src/core/template-manager.tsx @@ -105,6 +105,8 @@ export const TemplateManager: FC = ({ init, onTemplatesRen onContainerRemoved, }); + subscribeOnRemoval(containerElement, onContainerRemoved); + setInstantiationModels({ collection }); return containerElement; diff --git a/packages/devextreme/js/__internal/grids/new/card_view/header_panel/__snapshots__/options.test.ts.snap b/packages/devextreme/js/__internal/grids/new/card_view/header_panel/__snapshots__/options.test.ts.snap index 16241304944d..8929c49d0d90 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/header_panel/__snapshots__/options.test.ts.snap +++ b/packages/devextreme/js/__internal/grids/new/card_view/header_panel/__snapshots__/options.test.ts.snap @@ -128,10 +128,12 @@ exports[`ColumnProperties headerItemTemplate should override content of headerPa tabindex="0" >
-
- Column 1 +
+
+ Column 1 +
@@ -279,10 +281,12 @@ exports[`Options headerPanel itemTemplate should override content of headerPanel tabindex="0" >
-
- Column 1 +
+
+ Column 1 +
diff --git a/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts b/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts index d6d8b4573f3c..26780c447301 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts +++ b/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts @@ -4,6 +4,8 @@ import { describe, expect, it, jest, } from '@jest/globals'; import $ from '@js/core/renderer'; +import { on } from '@js/events/index'; +import type { CardInfo } from '@ts/grids/new/grid_core/columns_controller/types'; import { rerender } from 'inferno'; import type { Options as CardViewOptions } from './options'; @@ -69,6 +71,55 @@ describe('regressions', () => { }); }); +describe('card template container', () => { + it('should render card template into a new container and remove the previous one on data update (T1336765)', () => { + const containers: Element[] = []; + const onContainerRemoved = jest.fn(); + const cardTemplate = (_: unknown, templateContainer: Element): void => { + const element = $(templateContainer).get(0); + containers.push(element); + on(element, 'dxremove', onContainerRemoved); + }; + + const container = document.createElement('div'); + const cardView = new CardView(container, { + keyExpr: 'id', + dataSource: [{ id: 1, name: 'Audi' }], + cardTemplate, + } as CardViewOptions); + + cardView.option('dataSource', [{ id: 1, name: 'BMW' }]); + + expect(containers).toHaveLength(2); + expect(containers[1]).not.toBe(containers[0]); + expect(containers[0].parentNode).toBeNull(); + expect(onContainerRemoved).toHaveBeenCalledTimes(1); + }); + + it('should remove card template container when the card is removed (T1336765)', () => { + const removedCardKeys: unknown[] = []; + const cardTemplate = (model: unknown, templateContainer: Element): void => { + const { card } = model as { card: CardInfo }; + + on($(templateContainer).get(0), 'dxremove', () => { + removedCardKeys.push(card.key); + }); + }; + + const container = document.createElement('div'); + const dataSource = [{ id: 1, name: 'Audi' }, { id: 2, name: 'BMW' }]; + const cardView = new CardView(container, { + keyExpr: 'id', + dataSource, + cardTemplate, + } as CardViewOptions); + + cardView.option('dataSource', [dataSource[0]]); + + expect(removedCardKeys).toContain(2); + }); +}); + describe('absence of multiple re-render', () => { const dataSource = [ { id: 1, name: 'Audi' }, diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/template_wrapper.tsx b/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/template_wrapper.tsx index 808ac2c4bf8f..0473f9ad5f2d 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/template_wrapper.tsx +++ b/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/template_wrapper.tsx @@ -13,11 +13,14 @@ export function TemplateWrapper(template: TemplateType) { return class Template extends BaseInfernoComponent { private readonly ref = createRef(); + private $container?: dxElementWrapper; + private renderTemplate(): void { - $(this.ref.current).empty(); + this.$container?.remove(); + this.$container = $('
').appendTo($(this.ref.current)); template.render({ - container: $(this.ref.current), + container: this.$container, model: this.props, }); } @@ -33,5 +36,9 @@ export function TemplateWrapper(template: TemplateType) { public componentDidMount(): void { this.renderTemplate(); } + + public componentWillUnmount(): void { + this.$container?.remove(); + } }; } From 21d14137e6af6ea325eb505e6a5e5912085dd8b4 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:46:07 +0400 Subject: [PATCH 2/4] fix(grids): skip CardView template re-render on context change (T1336765) --- .../grids/new/card_view/widget.test.ts | 17 +++++++++++++++++ .../inferno_wrappers/template_wrapper.tsx | 9 ++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts b/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts index 26780c447301..4248053f7926 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts +++ b/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts @@ -201,6 +201,23 @@ describe('absence of multiple re-render', () => { expect(cardTemplate).toHaveBeenCalledTimes(calledTimes); }); + + it('should not render card template when integration templates are re-set (T1336765)', () => { + const cardTemplate = jest.fn(); + + const container = document.createElement('div'); + const cardView = new CardView(container, { + keyExpr: 'id', + dataSource, + columns, + cardTemplate, + } as CardViewOptions); + + cardTemplate.mockClear(); + cardView.option('integrationOptions.templates', {}); + + expect(cardTemplate).not.toHaveBeenCalled(); + }); }); describe('card footer template', () => { diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/template_wrapper.tsx b/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/template_wrapper.tsx index 0473f9ad5f2d..ce4d56a3497c 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/template_wrapper.tsx +++ b/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/template_wrapper.tsx @@ -1,6 +1,7 @@ import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import { BaseInfernoComponent } from '@ts/core/r1/runtime/inferno/base_component'; +import { shallowEquals } from '@ts/core/r1/utils/shallow_equals'; import { createRef } from 'inferno'; interface TemplateType { @@ -9,7 +10,9 @@ interface TemplateType { // eslint-disable-next-line @stylistic/max-len // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types -export function TemplateWrapper(template: TemplateType) { +export function TemplateWrapper< + TProps extends Record = Record, +>(template: TemplateType) { return class Template extends BaseInfernoComponent { private readonly ref = createRef(); @@ -25,6 +28,10 @@ export function TemplateWrapper(template: TemplateType) { }); } + public shouldComponentUpdate(nextProps: TProps): boolean { + return !shallowEquals(this.props, nextProps); + } + public render(): JSX.Element { return
; } From 88e93d4f331a960b7fec72a1142e243a649551d2 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:44:13 +0400 Subject: [PATCH 3/4] refactor(react): drop render-time template container subscription --- packages/devextreme-react/src/core/template-manager.tsx | 4 ---- packages/devextreme-react/src/core/types.ts | 1 - 2 files changed, 5 deletions(-) diff --git a/packages/devextreme-react/src/core/template-manager.tsx b/packages/devextreme-react/src/core/template-manager.tsx index d1cf625d4858..3b18e890d5f4 100644 --- a/packages/devextreme-react/src/core/template-manager.tsx +++ b/packages/devextreme-react/src/core/template-manager.tsx @@ -102,7 +102,6 @@ export const TemplateManager: FC = ({ init, onTemplatesRen } }, onRemoved, - onContainerRemoved, }); subscribeOnRemoval(containerElement, onContainerRemoved); @@ -195,10 +194,7 @@ export const TemplateManager: FC = ({ init, onTemplatesRen componentKey, onRendered, onRemoved, - onContainerRemoved, }]) => { - subscribeOnRemoval(container, onContainerRemoved); - const factory = templateFactories.current[templateKey]; if (factory) { diff --git a/packages/devextreme-react/src/core/types.ts b/packages/devextreme-react/src/core/types.ts index c8a1b59470ff..18eac2f032ac 100644 --- a/packages/devextreme-react/src/core/types.ts +++ b/packages/devextreme-react/src/core/types.ts @@ -87,7 +87,6 @@ export interface TemplateInstantiationModel { index: any; onRendered: () => void; onRemoved: (componentKey: string) => void; - onContainerRemoved: () => void; } export type GetRenderFuncFn = (templateKey: string) => RenderFunc; From cd1c5e123f01757c22a9554a686174bc84b1fbd2 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:47:58 +0400 Subject: [PATCH 4/4] fix(grids): clean CardView template container in place --- .../core/__tests__/template-manager.test.tsx | 36 ++++++++++++++++++- .../src/core/template-manager.tsx | 6 ++++ packages/devextreme-react/src/core/types.ts | 1 + .../__snapshots__/options.test.ts.snap | 20 +++++------ .../grids/new/card_view/widget.test.ts | 10 ++---- .../inferno_wrappers/template_wrapper.tsx | 13 ++++--- 6 files changed, 60 insertions(+), 26 deletions(-) diff --git a/packages/devextreme-react/src/core/__tests__/template-manager.test.tsx b/packages/devextreme-react/src/core/__tests__/template-manager.test.tsx index 3a70eb8e37d7..f519b710a5e5 100644 --- a/packages/devextreme-react/src/core/__tests__/template-manager.test.tsx +++ b/packages/devextreme-react/src/core/__tests__/template-manager.test.tsx @@ -635,4 +635,38 @@ describe('Template Manager', () => { expect(container.innerHTML).toBe(''); }); -}); \ No newline at end of file + + it('does not keep a container removal handler of a replaced template registration (T1336765)', () => { + let createDXTemplates; + + const init = ({ createDXTemplates: createDXTemplatesFn }: InitArgument) => { + createDXTemplates = createDXTemplatesFn; + }; + + const templateOptions = getTemplateOptions([{ type: 'render' }]); + + render( + +
+ undefined} /> +
+ ); + + let dxTemplates; + + act(() => { dxTemplates = createDXTemplates(templateOptions); }); + + const container = document.querySelector('.render-template-container')!; + const model = { text: 'Render template text' }; + + act(() => { + dxTemplates.renderKey.render({ model, index: 1, container }); + dxTemplates.renderKey.render({ model, index: 1, container }); + }); + + act(() => { events.triggerHandler(container, 'dxremove'); }); + + expect(container.innerHTML) + .toBe('
Render template text-1
'); + }); +}); diff --git a/packages/devextreme-react/src/core/template-manager.tsx b/packages/devextreme-react/src/core/template-manager.tsx index 3b18e890d5f4..5286d0c4a745 100644 --- a/packages/devextreme-react/src/core/template-manager.tsx +++ b/packages/devextreme-react/src/core/template-manager.tsx @@ -89,6 +89,11 @@ export const TemplateManager: FC = ({ init, onTemplatesRen }; const hostWidgetId = widgetId.current; + const replacedModel = collection.get(key); + + if (replacedModel) { + unsubscribeOnRemoval(containerElement, replacedModel.onContainerRemoved); + } collection.set(key, { templateKey, @@ -102,6 +107,7 @@ export const TemplateManager: FC = ({ init, onTemplatesRen } }, onRemoved, + onContainerRemoved, }); subscribeOnRemoval(containerElement, onContainerRemoved); diff --git a/packages/devextreme-react/src/core/types.ts b/packages/devextreme-react/src/core/types.ts index 18eac2f032ac..c8a1b59470ff 100644 --- a/packages/devextreme-react/src/core/types.ts +++ b/packages/devextreme-react/src/core/types.ts @@ -87,6 +87,7 @@ export interface TemplateInstantiationModel { index: any; onRendered: () => void; onRemoved: (componentKey: string) => void; + onContainerRemoved: () => void; } export type GetRenderFuncFn = (templateKey: string) => RenderFunc; diff --git a/packages/devextreme/js/__internal/grids/new/card_view/header_panel/__snapshots__/options.test.ts.snap b/packages/devextreme/js/__internal/grids/new/card_view/header_panel/__snapshots__/options.test.ts.snap index 8929c49d0d90..16241304944d 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/header_panel/__snapshots__/options.test.ts.snap +++ b/packages/devextreme/js/__internal/grids/new/card_view/header_panel/__snapshots__/options.test.ts.snap @@ -128,12 +128,10 @@ exports[`ColumnProperties headerItemTemplate should override content of headerPa tabindex="0" >
-
-
- Column 1 -
+
+ Column 1
@@ -281,12 +279,10 @@ exports[`Options headerPanel itemTemplate should override content of headerPanel tabindex="0" >
-
-
- Column 1 -
+
+ Column 1
diff --git a/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts b/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts index 4248053f7926..f57230dd6237 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts +++ b/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts @@ -72,13 +72,10 @@ describe('regressions', () => { }); describe('card template container', () => { - it('should render card template into a new container and remove the previous one on data update (T1336765)', () => { - const containers: Element[] = []; + it('should notify card template container removal before re-rendering on data update (T1336765)', () => { const onContainerRemoved = jest.fn(); const cardTemplate = (_: unknown, templateContainer: Element): void => { - const element = $(templateContainer).get(0); - containers.push(element); - on(element, 'dxremove', onContainerRemoved); + on($(templateContainer).get(0), 'dxremove', onContainerRemoved); }; const container = document.createElement('div'); @@ -90,9 +87,6 @@ describe('card template container', () => { cardView.option('dataSource', [{ id: 1, name: 'BMW' }]); - expect(containers).toHaveLength(2); - expect(containers[1]).not.toBe(containers[0]); - expect(containers[0].parentNode).toBeNull(); expect(onContainerRemoved).toHaveBeenCalledTimes(1); }); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/template_wrapper.tsx b/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/template_wrapper.tsx index ce4d56a3497c..7afe325ce374 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/template_wrapper.tsx +++ b/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/template_wrapper.tsx @@ -1,3 +1,4 @@ +import { cleanDataRecursive } from '@js/core/element_data'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import { BaseInfernoComponent } from '@ts/core/r1/runtime/inferno/base_component'; @@ -16,14 +17,16 @@ export function TemplateWrapper< return class Template extends BaseInfernoComponent { private readonly ref = createRef(); - private $container?: dxElementWrapper; + private clearContainer(): void { + cleanDataRecursive(this.ref.current, true); + $(this.ref.current).empty(); + } private renderTemplate(): void { - this.$container?.remove(); - this.$container = $('
').appendTo($(this.ref.current)); + this.clearContainer(); template.render({ - container: this.$container, + container: $(this.ref.current), model: this.props, }); } @@ -45,7 +48,7 @@ export function TemplateWrapper< } public componentWillUnmount(): void { - this.$container?.remove(); + this.clearContainer(); } }; }