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..f519b710a5e5 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,72 @@ describe('Template Manager', () => {
expect(document.querySelector('.render-template-container')?.innerHTML)
.toBe('Render template text-1
');
});
-});
\ No newline at end of file
+
+ 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('');
+ });
+
+ 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 82c7b6a90a85..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,
@@ -105,6 +110,8 @@ export const TemplateManager: FC = ({ init, onTemplatesRen
onContainerRemoved,
});
+ subscribeOnRemoval(containerElement, onContainerRemoved);
+
setInstantiationModels({ collection });
return containerElement;
@@ -193,10 +200,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/js/__internal/grids/new/card_view/widget.test.ts b/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts
index d6d8b4573f3c..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
@@ -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,49 @@ describe('regressions', () => {
});
});
+describe('card template container', () => {
+ it('should notify card template container removal before re-rendering on data update (T1336765)', () => {
+ const onContainerRemoved = jest.fn();
+ const cardTemplate = (_: unknown, templateContainer: Element): void => {
+ on($(templateContainer).get(0), '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(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' },
@@ -150,6 +195,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 808ac2c4bf8f..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,6 +1,8 @@
+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';
+import { shallowEquals } from '@ts/core/r1/utils/shallow_equals';
import { createRef } from 'inferno';
interface TemplateType {
@@ -9,12 +11,19 @@ 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();
- private renderTemplate(): void {
+ private clearContainer(): void {
+ cleanDataRecursive(this.ref.current, true);
$(this.ref.current).empty();
+ }
+
+ private renderTemplate(): void {
+ this.clearContainer();
template.render({
container: $(this.ref.current),
@@ -22,6 +31,10 @@ export function TemplateWrapper(template: TemplateType) {
});
}
+ public shouldComponentUpdate(nextProps: TProps): boolean {
+ return !shallowEquals(this.props, nextProps);
+ }
+
public render(): JSX.Element {
return ;
}
@@ -33,5 +46,9 @@ export function TemplateWrapper(template: TemplateType) {
public componentDidMount(): void {
this.renderTemplate();
}
+
+ public componentWillUnmount(): void {
+ this.clearContainer();
+ }
};
}