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"
>
@@ -279,10 +281,12 @@ exports[`Options headerPanel itemTemplate should override content of headerPanel
tabindex="0"
>
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();
}
};
}